mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
fix(runner): commit receipts only for admitted sidecar frames
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
ffdb1bce20
commit
450135c914
10 files changed
+126
-20
No files matched your search
@@ -11,7 +11,11 @@ failed even though its owned processes, sandbox and IPC retired cleanly.
|
||||
Receipt v2 preserves the original `inputSha256` and separately records
|
||||
`normalizedInputSha256` at the same invocation's actual validated forwarding
|
||||
boundary. The capture commits only after that emission succeeds; absent or
|
||||
invalid capture remains null. Native result matching still requires the exact
|
||||
invalid capture remains null. The sidecar rejects an oversized frame or semantic
|
||||
body that its Unicode encoder would change before committing the digest.
|
||||
Rejected frames consume no stream sequence. Writable backpressure still means
|
||||
the bytes were accepted into the stream buffer; it does not prove semantic
|
||||
acceptance. Native result matching still requires the exact
|
||||
invocation-owned receipt and all original call/input/result hashes. Acceptance
|
||||
must independently match the normalized digest and the unique proposed/accepted
|
||||
result bodies. Transport return alone never proves acceptance. The server
|
||||
|
||||
@@ -4,6 +4,8 @@ Current source checkpoint (2026-10-01): **Cursor v10, Copilot v11 and Pi v10
|
||||
remain unqualified**. Copilot receipt v2 distinguishes original provider arguments
|
||||
from the strictly validated input actually forwarded for completion. A same-call
|
||||
capture binds both hashes without reconstructing omitted defaults in the grader.
|
||||
Oversized frames and semantic bodies changed by Unicode repair fail before
|
||||
capture commitment, and dropped frames do not consume stream sequence numbers.
|
||||
The server's JWT heuristic now preserves only the fixed schema literal in a
|
||||
validated semantic-receipt notice. Native call/result correlation and independent
|
||||
canonical acceptance remain required. The failed v10 Daytona attempt is retained;
|
||||
|
||||
@@ -223,7 +223,7 @@ impl AcpxProviderDescriptor {
|
||||
"1.0.88",
|
||||
None,
|
||||
None,
|
||||
"sha256:e3057abcc2e6eb0db3a5c683a15e32159ac3caa78ba3d4c2a5d9221929e867dd",
|
||||
"sha256:d56589c43437277b527ed61155de7d882e3ae5316d67502d8b98a0d435f69f99",
|
||||
),
|
||||
"grok" => (
|
||||
"grok-4.7",
|
||||
@@ -2812,7 +2812,7 @@ mod tests {
|
||||
"copilot",
|
||||
"@github/copilot",
|
||||
"1.0.88",
|
||||
"sha256:e3057abcc2e6eb0db3a5c683a15e32159ac3caa78ba3d4c2a5d9221929e867dd",
|
||||
"sha256:d56589c43437277b527ed61155de7d882e3ae5316d67502d8b98a0d435f69f99",
|
||||
None,
|
||||
None,
|
||||
"explicit-model",
|
||||
|
||||
@@ -7,7 +7,7 @@ import { COPILOT_VERSION, materializePinnedCopilotBinary, resolveCopilotDistribu
|
||||
|
||||
const MAX_ARCHIVE_BYTES = 128 * 1024 * 1024;
|
||||
const MAX_EXPANDED_BYTES = 384 * 1024 * 1024;
|
||||
const PROFILE_DIGEST = "sha256:e3057abcc2e6eb0db3a5c683a15e32159ac3caa78ba3d4c2a5d9221929e867dd";
|
||||
const PROFILE_DIGEST = "sha256:d56589c43437277b527ed61155de7d882e3ae5316d67502d8b98a0d435f69f99";
|
||||
|
||||
/** Build-time only; outputRoot is the exact runner-owned platform asset directory. */
|
||||
export async function buildPinnedCopilotDistribution({ outputRoot, platform = process.platform, architecture = process.arch }, { fetchImpl = fetch } = {}) {
|
||||
|
||||
@@ -15,7 +15,7 @@ import { deliverAcpxResponse } from "../drivers/acpx/response-delivery.js";
|
||||
import { normalizeAcpxPermission } from "../drivers/acpx/acp-permission-adapter.js";
|
||||
import { ACPX_CAPABILITY_PROFILES } from "../drivers/acpx/capability-profiles.js";
|
||||
import { resolveQualifiedAcpxProfile } from "../drivers/acpx/qualified-profiles.js";
|
||||
import { ACPX_SIDECAR_PROTOCOL_VERSION } from "../drivers/acpx/sidecar-protocol.js";
|
||||
import { ACPX_SIDECAR_PROTOCOL_VERSION, ACPX_SIDECAR_MAX_FRAME_BYTES, stringifyAcpxSidecarFrame } from "../drivers/acpx/sidecar-protocol.js";
|
||||
import { canonicalProviderEventsFromAcpxRuntimeEvent } from "../provider-events.js";
|
||||
import { createPiMessageProjection } from "../drivers/acpx/pi-message-projection.js";
|
||||
import { createCopilotToolEvidence } from "../drivers/acpx/copilot-tool-evidence.js";
|
||||
@@ -280,6 +280,67 @@ describe("qualified ACPX runtime sidecar", () => {
|
||||
expect(emitted).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects normalized/envelope expansion at the actual frame writer before committing a receipt", async () => {
|
||||
const raw = { reportedWorkDisposition: "done", summary: "Complete", evidence: [{ ref: "" }], verification: [],
|
||||
completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [], remainingWork: [] } };
|
||||
const request = { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "paperclip_finish", arguments: raw } };
|
||||
raw.evidence[0]!.ref = "x".repeat(ACPX_SIDECAR_MAX_FRAME_BYTES - 1 - Buffer.byteLength(JSON.stringify(request)));
|
||||
expect(Buffer.byteLength(JSON.stringify(request))).toBe(ACPX_SIDECAR_MAX_FRAME_BYTES - 1);
|
||||
const validation = validatePrpStructuredRunResult(raw);
|
||||
if (!validation.ok) throw new Error("Invalid fixture");
|
||||
expect(Buffer.byteLength(JSON.stringify(validation.result))).toBeLessThanOrEqual(ACPX_SIDECAR_MAX_FRAME_BYTES);
|
||||
const wire = loadActualSidecarWriter();
|
||||
const tools = new Map<string, unknown>(), emitted: unknown[] = [];
|
||||
const handler = loadWaitForTool({ tools, emitted, validate: validatePrpStructuredRunResult, emit: wire.emit });
|
||||
const bridge = await startRunnerToolBridge({ handler, captureSemanticReceipt: () => () => {} });
|
||||
try {
|
||||
const body = await (await fetch(bridge.url, { method: "POST", headers: { Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(request) })).json();
|
||||
expect(body.result.isError).toBe(true);
|
||||
expect(body.result.content[0].text).toBe("ACPX semantic tool call exceeds the sidecar frame limit");
|
||||
expect(readNativeSemanticReceipt({ contents: body.result.content })).toMatchObject({ outcome: "error", normalizedInputSha256: null });
|
||||
expect(tools.size).toBe(0);
|
||||
expect(emitted).toEqual([]);
|
||||
expect(wire.writes).toEqual([]);
|
||||
expect(wire.errors).toEqual(["[paperclip-acpx-sidecar] output_frame_too_large\n"]);
|
||||
expect(wire.sequence()).toBe(0);
|
||||
expect(wire.emit("runtime.diagnostic", { code: "bounded", message: "rejected" })).toBe(true);
|
||||
expect(JSON.parse(wire.writes[0]!)).toMatchObject({ sequence: 1, eventType: "runtime.diagnostic" });
|
||||
} finally { await bridge.close(); }
|
||||
});
|
||||
|
||||
it.each(["backpressure", "throws"])("uses actual writer acceptance, preserving %s semantics", async mode => {
|
||||
const wire = loadActualSidecarWriter(mode);
|
||||
const tools = new Map<string, any>(), emitted: any[] = [], commit = vi.fn(), capture = vi.fn((_input: unknown) => commit);
|
||||
const raw = { reportedWorkDisposition: "done", summary: "Complete", evidence: [], verification: [],
|
||||
completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [], remainingWork: [] } };
|
||||
const handler = loadWaitForTool({ tools, emitted, validate: validatePrpStructuredRunResult, emit: wire.emit });
|
||||
const pending = handler({ tool: "paperclip_finish", callId: "3", arguments: raw, signal: new AbortController().signal, captureNormalizedInput: capture });
|
||||
if (mode === "throws") {
|
||||
await expect(pending).rejects.toThrow("fixture write failed");
|
||||
expect(commit).not.toHaveBeenCalled(); expect(tools.size).toBe(0); expect(wire.sequence()).toBe(0);
|
||||
} else {
|
||||
expect(commit).toHaveBeenCalledOnce(); expect(tools.has("3")).toBe(true); expect(wire.sequence()).toBe(1);
|
||||
expect(JSON.parse(wire.writes[0]!).payload.input).toEqual(capture.mock.calls[0]![0]);
|
||||
tools.get("3").settle({ accepted: true }); tools.get("3").cleanup();
|
||||
await expect(pending).resolves.toEqual({ accepted: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["value", "property", "collision"])("rejects semantic %s Unicode rewriting before capture or write", async kind => {
|
||||
const raw = { reportedWorkDisposition: "done", summary: kind === "value" ? "\ud800" : "Complete", evidence: [{ ref: "fixture" } as Record<string, string>], verification: [],
|
||||
completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [], remainingWork: [] } };
|
||||
if (kind !== "value") raw.evidence[0]!["\udc00"] = "one";
|
||||
if (kind === "collision") raw.evidence[0]!["\ufffd"] = "two";
|
||||
expect(validatePrpStructuredRunResult(raw).ok).toBe(true);
|
||||
const wire = loadActualSidecarWriter(), tools = new Map<string, unknown>(), emitted: unknown[] = [], capture = vi.fn(() => vi.fn());
|
||||
const handler = loadWaitForTool({ tools, emitted, validate: validatePrpStructuredRunResult, emit: wire.emit });
|
||||
await expect(handler({ tool: "paperclip_finish", callId: "3", arguments: raw, signal: new AbortController().signal,
|
||||
captureNormalizedInput: capture })).rejects.toThrow("cannot be encoded without changing its identity");
|
||||
expect(capture).not.toHaveBeenCalled(); expect(tools.size).toBe(0); expect(emitted).toEqual([]);
|
||||
expect(wire.writes).toEqual([]); expect(wire.sequence()).toBe(0);
|
||||
});
|
||||
|
||||
it("passes only validated Pi native boundaries and history through the real text sanitizer", () => {
|
||||
const source = readFileSync(fileURLToPath(new URL("./acpx-runtime-sidecar.ts", import.meta.url)), "utf8");
|
||||
const start = source.indexOf(' if (event.type === "text_delta") {', source.indexOf("function sanitizeRuntimeEvent"));
|
||||
@@ -1062,7 +1123,7 @@ function loadWaitForTool(input: {
|
||||
tools: Map<string, unknown>;
|
||||
emitted: unknown[];
|
||||
validate?: typeof validatePrpStructuredRunResult;
|
||||
emit?: () => void;
|
||||
emit?: (eventType: string, payload: unknown) => boolean;
|
||||
}): (call: RunnerToolCall) => Promise<unknown> {
|
||||
const source = readFileSync(
|
||||
fileURLToPath(new URL("./acpx-runtime-sidecar.ts", import.meta.url)),
|
||||
@@ -1079,14 +1140,14 @@ function loadWaitForTool(input: {
|
||||
);
|
||||
const factory = new Function(
|
||||
"boundedIdentity", "tools", "turnId", "emit", "PRP_COMPLETION_TOOL_NAME",
|
||||
"PRP_BLOCK_TOOL_NAME", "validatePrpStructuredRunResult", "boundedSidecarValue", "record", "MAX_PENDING_TOOLS",
|
||||
"PRP_BLOCK_TOOL_NAME", "validatePrpStructuredRunResult", "boundedSidecarValue", "record", "MAX_PENDING_TOOLS", "stringifyAcpxSidecarFrame",
|
||||
`return (${functionSource});`,
|
||||
);
|
||||
return factory(
|
||||
(value: string) => value,
|
||||
input.tools,
|
||||
"test-turn",
|
||||
(_eventType: string, payload: unknown) => { input.emit?.(); input.emitted.push(payload); },
|
||||
(eventType: string, payload: unknown) => { const accepted = input.emit?.(eventType, payload) ?? true; if (accepted) input.emitted.push(payload); return accepted; },
|
||||
"paperclip_finish",
|
||||
"paperclip_block",
|
||||
input.validate ?? ((argumentsValue: unknown) => ({
|
||||
@@ -1096,5 +1157,26 @@ function loadWaitForTool(input: {
|
||||
(value: unknown) => value,
|
||||
(value: unknown) => value,
|
||||
512,
|
||||
stringifyAcpxSidecarFrame,
|
||||
);
|
||||
}
|
||||
|
||||
/** Actual producer and encoder, with only the destination stream replaced. */
|
||||
function loadActualSidecarWriter(mode: "normal" | "backpressure" | "throws" = "normal"): {
|
||||
emit: (eventType: string, payload: unknown) => boolean; sequence: () => number; writes: string[]; errors: string[];
|
||||
} {
|
||||
const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8");
|
||||
const emitStart = source.indexOf("function emit("), emitEnd = source.indexOf("\nfunction diagnostic", emitStart);
|
||||
const writeStart = source.indexOf("function writeFrame("), writeEnd = source.indexOf("\nfunction requireHost", writeStart);
|
||||
if (emitStart < 0 || emitEnd < 0 || writeStart < 0 || writeEnd < 0) throw new Error("Actual sidecar writer source not found");
|
||||
const writes: string[] = [], errors: string[] = [];
|
||||
const process = { stdout: { write: (line: string) => { if (mode === "throws") throw new Error("fixture write failed"); writes.push(line); return mode !== "backpressure"; } },
|
||||
stderr: { write: (line: string) => { errors.push(line); return true; } } };
|
||||
const writer = new Function("process", "stringifyAcpxSidecarFrame", "ACPX_SIDECAR_PROTOCOL_VERSION", "ACPX_SIDECAR_MAX_FRAME_BYTES", `
|
||||
let sequence=0; const runId="test-run", turnId="test-turn";
|
||||
${stripTypeScriptTypes(source.slice(emitStart, emitEnd))}
|
||||
${stripTypeScriptTypes(source.slice(writeStart, writeEnd))}
|
||||
return { emit, sequence: () => sequence };
|
||||
`)(process, stringifyAcpxSidecarFrame, ACPX_SIDECAR_PROTOCOL_VERSION, ACPX_SIDECAR_MAX_FRAME_BYTES);
|
||||
return { ...writer, writes, errors };
|
||||
}
|
||||
@@ -711,6 +711,15 @@ async function waitForTool(call: RunnerToolCall): Promise<unknown> {
|
||||
"ACPX semantic result disposition does not match its terminal operation",
|
||||
);
|
||||
}
|
||||
// Semantic result identity must survive the wire encoder unchanged. Its
|
||||
// display-text Unicode repair is unsuitable for authenticated result data.
|
||||
try {
|
||||
if (stringifyAcpxSidecarFrame(validation.result) !== JSON.stringify(validation.result)) {
|
||||
throw new Error("Semantic input changes during encoding");
|
||||
}
|
||||
} catch {
|
||||
throw new Error("ACPX semantic result cannot be encoded without changing its identity");
|
||||
}
|
||||
// The authenticated runner bridge must admit this built-in invocation
|
||||
// before the provider sees a result. Keep the call pending until runnerd
|
||||
// sends tool.resolve after the server's completion feedback accepts it.
|
||||
@@ -718,7 +727,7 @@ async function waitForTool(call: RunnerToolCall): Promise<unknown> {
|
||||
// local semantic_result here would let an invalid review handoff appear
|
||||
// accepted before the server has checked it.
|
||||
const commitNormalizedInput = call.captureNormalizedInput?.(validation.result);
|
||||
emit(
|
||||
const forwarded = emit(
|
||||
"runtime.tool_called",
|
||||
{
|
||||
callId,
|
||||
@@ -727,6 +736,7 @@ async function waitForTool(call: RunnerToolCall): Promise<unknown> {
|
||||
},
|
||||
activeTurnId,
|
||||
);
|
||||
if (!forwarded) throw new Error("ACPX semantic tool call exceeds the sidecar frame limit");
|
||||
commitNormalizedInput?.();
|
||||
return await new Promise((settle, reject) => {
|
||||
const abort = () => {
|
||||
@@ -1327,18 +1337,21 @@ function emit(
|
||||
eventType: AcpxSidecarEvent["eventType"],
|
||||
payload: Record<string, unknown>,
|
||||
eventTurnId: string | null = turnId,
|
||||
): void {
|
||||
): boolean {
|
||||
if (sequence >= Number.MAX_SAFE_INTEGER) {
|
||||
throw new Error("ACPX sidecar event sequence exhausted");
|
||||
}
|
||||
writeFrame({
|
||||
const accepted = writeFrame({
|
||||
protocolVersion: ACPX_SIDECAR_PROTOCOL_VERSION,
|
||||
sequence: ++sequence,
|
||||
sequence: sequence + 1,
|
||||
eventType,
|
||||
runId,
|
||||
turnId: eventTurnId,
|
||||
payload,
|
||||
});
|
||||
// A dropped frame never entered the stream and must not consume an identity.
|
||||
if (accepted) sequence++;
|
||||
return accepted;
|
||||
}
|
||||
|
||||
function diagnostic(code: string, message: string): void {
|
||||
@@ -1362,13 +1375,16 @@ function response(
|
||||
});
|
||||
}
|
||||
|
||||
function writeFrame(value: AcpxSidecarEvent | AcpxSidecarResponse): void {
|
||||
function writeFrame(value: AcpxSidecarEvent | AcpxSidecarResponse): boolean {
|
||||
const line = stringifyAcpxSidecarFrame(value);
|
||||
if (Buffer.byteLength(line) > ACPX_SIDECAR_MAX_FRAME_BYTES) {
|
||||
process.stderr.write("[paperclip-acpx-sidecar] output_frame_too_large\n");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
process.stdout.write(`${line}\n`);
|
||||
// Writable.write(false) accepted the bytes into its queue; it signals
|
||||
// backpressure, not a rejected frame. A synchronous write error still throws.
|
||||
return true;
|
||||
}
|
||||
|
||||
function requireHost(
|
||||
|
||||
@@ -24,6 +24,7 @@ const SEMANTIC_RECEIPT_SOURCES = [
|
||||
["../runner-tool-bridge.ts", "semanticBridgeSourceSha256"],
|
||||
["copilot-tool-evidence.ts", "toolEvidenceSourceSha256"],
|
||||
["../../cli/acpx-runtime-sidecar.ts", "semanticSidecarSourceSha256"],
|
||||
["sidecar-protocol.ts", "semanticSidecarProtocolSourceSha256"],
|
||||
["codex-acpx-driver.ts", "semanticDirectDriverSourceSha256"],
|
||||
["../../protocol/replay-contract.ts", "semanticValidationSourceSha256"],
|
||||
["../../protocol/result-normalization.ts", "semanticNormalizationSourceSha256"],
|
||||
|
||||
@@ -75,7 +75,7 @@ export const QUALIFIED_ACPX_PROFILES: Readonly<
|
||||
acpxVersion: QUALIFIED_ACPX_VERSION, agent: "copilot", agentProfileVersion: 11,
|
||||
agentServerPackage: "@github/copilot", agentServerVersion: "1.0.88",
|
||||
agentRuntimePackage: null, agentRuntimeVersion: null,
|
||||
commandDigest: "sha256:e3057abcc2e6eb0db3a5c683a15e32159ac3caa78ba3d4c2a5d9221929e867dd",
|
||||
commandDigest: "sha256:d56589c43437277b527ed61155de7d882e3ae5316d67502d8b98a0d435f69f99",
|
||||
// Authenticated discovery has not established a qualification model. Never
|
||||
// turn this empty declaration into a default; callers must select an ID.
|
||||
qualificationModel: "", reportedModelId: "", permissionPolicy: "interactive",
|
||||
|
||||
@@ -758,8 +758,8 @@ describe("ACPX runtime host", () => {
|
||||
const receipt = readNativeSemanticReceipt({ contents: body.result.content });
|
||||
expect(receipt).not.toBeNull();
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.payload).toMatchObject({ category: "paperclip_semantic_tool_receipt_v1", provenance: { sessionId: "backend-1", turnId: "turn-1" },
|
||||
details: expect.arrayContaining([{ name: "callIdentitySha256", value: receipt!.callIdentitySha256 }, { name: "resultSha256", value: receipt!.resultSha256 }]) });
|
||||
expect(events[0]!.payload).toMatchObject({ category: "paperclip_semantic_tool_receipt_v2", provenance: { sessionId: "backend-1", turnId: "turn-1" },
|
||||
details: expect.arrayContaining([{ name: "schema", value: "paperclip.semantic_tool_receipt.v2" }, { name: "normalizedInputSha256", value: "null" }, { name: "callIdentitySha256", value: receipt!.callIdentitySha256 }, { name: "resultSha256", value: receipt!.resultSha256 }]) });
|
||||
} finally { await host.close({ reason: "receipt forwarding verified" }); }
|
||||
await expect(fetch(bridge!.url)).rejects.toThrow();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"commandDigest": "sha256:e3057abcc2e6eb0db3a5c683a15e32159ac3caa78ba3d4c2a5d9221929e867dd",
|
||||
"commandDigest": "sha256:d56589c43437277b527ed61155de7d882e3ae5316d67502d8b98a0d435f69f99",
|
||||
"declaration": {
|
||||
"schema": "paperclip.acpx_profile_declaration.v1",
|
||||
"agent": "copilot",
|
||||
@@ -30,12 +30,13 @@
|
||||
"semanticBridgeSourceSha256": "8a43ebafb7a85dd1714ed6945670472666dcb63e8af32b4a74644163fa0e370d",
|
||||
"toolEvidenceSourceSha256": "4b517eed447615b3cc820af348ccc360978175149caa0bbc838d1f78332abe75",
|
||||
"semanticNormalizedInputContract": "validated-forwarded-input-commit-v1",
|
||||
"semanticSidecarSourceSha256": "6c7dedca55f0f031570349b010ede1cd8015c25e9a52afac01ce9de8beb966c2",
|
||||
"semanticSidecarSourceSha256": "a261497560840da66613a5f4a3dafeba05e53d3a8647c180c9604b3cf983dc23",
|
||||
"semanticDirectDriverSourceSha256": "31e36917ee3592fa6d8f4b86632129fdffae20c45aed2e0e367ccf523e064f88",
|
||||
"semanticValidationSourceSha256": "039fca52266fe21ba74ca64660d21228314d8ed737b19ba67941bfb25086e4d5",
|
||||
"semanticNormalizationSourceSha256": "b1ce59251bf9c671f66e0e5173646190b9a2d1bfc5bdb7fb123824213986b92b",
|
||||
"semanticCompletionContractSourceSha256": "00a684f9aea87f68eabf0974681c52461bc308631a1252a89c4ed087d7158b28",
|
||||
"semanticValidatorsSourceSha256": "950b49b233d8dd62ff858398becfc55f0a181160cb8a434d9221b5c17816867c",
|
||||
"semanticSchemaBundleSourceSha256": "bb55e18c5563bf5ec226e301c3399229304f295e986865e96554f193535368f2"
|
||||
"semanticSchemaBundleSourceSha256": "bb55e18c5563bf5ec226e301c3399229304f295e986865e96554f193535368f2",
|
||||
"semanticSidecarProtocolSourceSha256": "165cad76f224d60f0dc7928736d47a16787eaa8ab9e4c2222488da5fc8ad632e"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user