mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
fix(runner): require receiver acknowledgment for semantic receipt
This commit is contained in:
1 parent
2e4e06d7f0
commit
22c78242a4
15 files changed
+335
-51
No files matched your search
@@ -1,28 +1,30 @@
|
||||
# Copilot 1.0.88 rich ACP capability audit
|
||||
|
||||
Current source candidate (2026-10-01): **Copilot profile v11 is unqualified**.
|
||||
The authenticated Daytona v10 attempt exposed two independent receipt-boundary
|
||||
failures. Generic JWT redaction removed the public receipt schema name from a
|
||||
notice detail. Separately, a legitimate completion omitted optional fields that
|
||||
the strict result validator fills before forwarding; its original argument hash
|
||||
therefore differed from the proposed canonical result. The failed attempt remains
|
||||
failed even though its owned processes, sandbox and IPC retired cleanly.
|
||||
Current source candidate (2026-10-01): **Copilot profile v12 is unqualified**.
|
||||
Receipt v2 preserves the original `inputSha256` and separately captures
|
||||
`normalizedInputSha256` from the same invocation's validated outgoing body.
|
||||
For the native sidecar, only a successful, correlated `tool.resolve` commits
|
||||
that capture. A pipe write alone cannot attest delivery: Rust can reject an
|
||||
otherwise valid frame at its smaller payload admission limit. Explicit errors,
|
||||
cancellation, timeout, missing responses and stale or foreign responses leave
|
||||
the capture null. A returned `accepted:false` witnesses delivery but still
|
||||
rejects completion. The direct driver commits only after its proposal emission.
|
||||
|
||||
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. 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
|
||||
preserves only the fixed schema literal inside a validated receipt notice;
|
||||
adjacent credentials and JWT-shaped values remain redacted. Retained v10 warm
|
||||
sessions are incompatible. Native executable bytes are unchanged; new runtime
|
||||
packs and fresh local/Daytona qualification are required.
|
||||
Delivery does not prove canonical equality or completion acceptance. The grader
|
||||
must independently match the normalized digest with the unique proposed and
|
||||
accepted result bodies; receiver sanitization does not relax that comparison.
|
||||
The sidecar also rejects oversized frames or bodies its Unicode encoder would
|
||||
change. Dropped frames consume no stream sequence; writable backpressure is
|
||||
handled as an accepted buffer write, without claiming receiver admission.
|
||||
|
||||
The server preserves only the fixed schema literal inside a validated receipt
|
||||
notice; adjacent credentials and JWT-shaped values remain redacted. The paid
|
||||
v10 Daytona failure remains failed. The v11 build and offline CI evidence is
|
||||
historical and does not qualify this corrected source. Retained v10 and v11
|
||||
warm sessions are incompatible. Native executable bytes are unchanged; fresh
|
||||
runtime packs and local/Daytona qualification are required. Tests exercise the
|
||||
actual parsed sidecar command handler and the Rust subprocess transport,
|
||||
including rejection of a 300 KiB payload before pending-tool admission.
|
||||
|
||||
Historical source candidate (2026-10-01): **Copilot profile v10 was unqualified**.
|
||||
Admitted Paperclip MCP calls append a bounded `paperclip.semantic_tool_receipt.v1`
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
# Rich ACP integration and qualification report
|
||||
|
||||
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;
|
||||
fresh v11 runtime packaging and local/Daytona evidence are still required.
|
||||
The [Copilot inventory](runner-copilot-capabilities.md) records both boundaries.
|
||||
Current source checkpoint (2026-10-01): **Cursor v10, Copilot v12 and Pi v10
|
||||
remain unqualified**. Copilot receipt v2 distinguishes original provider
|
||||
arguments from the validated outgoing completion input. The sidecar commits
|
||||
the captured normalized digest only after its exact pending call receives a
|
||||
successful, turn-bound `tool.resolve`. Pipe-write success alone is insufficient:
|
||||
Rust may reject a frame at its smaller payload admission limit. Errors,
|
||||
cancellation, timeout and stale responses cannot attest delivery.
|
||||
|
||||
Delivery remains separate from canonical equality and acceptance. The grader
|
||||
still requires the normalized digest to match the unique proposed and accepted
|
||||
result bodies. Oversized frames and Unicode repair fail before commitment;
|
||||
dropped frames do not consume stream sequence numbers. The server preserves
|
||||
only the fixed schema literal inside a validated semantic-receipt notice.
|
||||
The failed v10 Daytona attempt and held v11 builds remain historical. Fresh
|
||||
v12 runtime packaging and local/Daytona evidence are required. The
|
||||
[Copilot inventory](runner-copilot-capabilities.md) records these boundaries.
|
||||
|
||||
Historical source checkpoint (2026-10-01): **Cursor v10, Copilot v10 and Pi v10
|
||||
remain unqualified**. Cursor now shares the bounded native tool-ID mapping
|
||||
|
||||
@@ -223,7 +223,7 @@ impl AcpxProviderDescriptor {
|
||||
"1.0.88",
|
||||
None,
|
||||
None,
|
||||
"sha256:d56589c43437277b527ed61155de7d882e3ae5316d67502d8b98a0d435f69f99",
|
||||
"sha256:48cecd8dc77a5533240fcf2f29d19be05380da4a79f8e5061480f94241db75a8",
|
||||
),
|
||||
"grok" => (
|
||||
"grok-4.7",
|
||||
@@ -2812,7 +2812,7 @@ mod tests {
|
||||
"copilot",
|
||||
"@github/copilot",
|
||||
"1.0.88",
|
||||
"sha256:d56589c43437277b527ed61155de7d882e3ae5316d67502d8b98a0d435f69f99",
|
||||
"sha256:48cecd8dc77a5533240fcf2f29d19be05380da4a79f8e5061480f94241db75a8",
|
||||
None,
|
||||
None,
|
||||
"explicit-model",
|
||||
@@ -2914,6 +2914,13 @@ mod tests {
|
||||
assert!(wrong_agent.validate(&context()).is_err());
|
||||
}
|
||||
if agent == "copilot" {
|
||||
let mut previous_v11 = value.clone();
|
||||
previous_v11["commandDigest"] = json!(
|
||||
"sha256:d56589c43437277b527ed61155de7d882e3ae5316d67502d8b98a0d435f69f99"
|
||||
);
|
||||
let previous_v11: AcpxProviderDescriptor =
|
||||
serde_json::from_value(previous_v11).unwrap();
|
||||
assert!(previous_v11.validate(&context()).is_err());
|
||||
let mut previous_v10 = value.clone();
|
||||
previous_v10["commandDigest"] = json!(
|
||||
"sha256:c6741d7e49cd1af4ecad9e07181ef96305bde48b56dce2958547f2f066666231"
|
||||
|
||||
@@ -25,6 +25,21 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.find(|pair| pair[0] == "--profile-digest")
|
||||
.map(|pair| pair[1].as_str())
|
||||
.unwrap_or("sha256:1111111111111111111111111111111111111111111111111111111111111111");
|
||||
// Only the receiver-admission fixture writes this task-owned command journal.
|
||||
let mut admission_journal = if matches!(mode, "admission-tool" | "admission-tool-oversized") {
|
||||
let path = args
|
||||
.windows(2)
|
||||
.find(|pair| pair[0] == "--journal")
|
||||
.ok_or("admission journal is missing")?;
|
||||
Some(
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&path[1])?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let stdin = io::stdin();
|
||||
let mut stdout = io::stdout().lock();
|
||||
let mut next_sequence = 1_u64;
|
||||
@@ -39,6 +54,34 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.get("command")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("request command is missing")?;
|
||||
if let Some(journal) = admission_journal.as_mut() {
|
||||
write_json(journal, &json!({"request":request}))?;
|
||||
let response = if command == "tool.resolve" {
|
||||
json!({"protocolVersion":GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,
|
||||
"id":id,"ok":true,"result":{"resolved":
|
||||
request["params"]["callId"] == "call-admission"
|
||||
&& request["params"]["turnId"] == "turn-admission"
|
||||
&& request["params"]["result"] == json!({"id":"issue-1"})
|
||||
&& request["params"]["error"].is_null()}})
|
||||
} else {
|
||||
bootstrap_success(id, command, &request, mode, profile_digest)
|
||||
};
|
||||
write_json(&mut stdout, &response)?;
|
||||
if command == "turn.start" {
|
||||
let frame = json!({
|
||||
"protocolVersion":GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,
|
||||
"sequence":next_sequence,"eventType":"runtime.tool_called",
|
||||
"runId":"run-1","turnId":request["params"]["turnId"],
|
||||
"payload":{"callId":"call-admission","operationId":"issues.read",
|
||||
"input":{"id":"issue-1","padding":"x".repeat(
|
||||
if mode == "admission-tool-oversized" { 300 * 1024 } else { 8 })}},
|
||||
});
|
||||
write_json(journal, &json!({"emitted":frame}))?;
|
||||
write_json(&mut stdout, &frame)?;
|
||||
next_sequence += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if mode == "goals" && command.starts_with("session.goal.") {
|
||||
let params = request.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
match command {
|
||||
|
||||
@@ -227,3 +227,100 @@ fn rejects_non_utf8_directories_before_spawning() {
|
||||
|
||||
assert!(error.contains("must be valid UTF-8"), "{error}");
|
||||
}
|
||||
|
||||
/// Exercise the subprocess transport, real event decoder/reducer and tool bridge.
|
||||
/// The command journal distinguishes no admission from a late sidecar rejection.
|
||||
fn check_tool_receiver_admission(oversized: bool) {
|
||||
let root = std::env::temp_dir().join(format!("acpx-admission-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let journal = root.join("commands.jsonl");
|
||||
let mut fixture = config(if oversized {
|
||||
"admission-tool-oversized"
|
||||
} else {
|
||||
"admission-tool"
|
||||
});
|
||||
fixture
|
||||
.transport
|
||||
.args
|
||||
.extend(["--journal".to_owned(), journal.to_str().unwrap().to_owned()]);
|
||||
let mut session = AcpxProviderSession::start(&fixture).unwrap();
|
||||
session
|
||||
.start_turn("turn-admission", "Read the issue", &std::env::temp_dir())
|
||||
.unwrap();
|
||||
let result = paperclip_runner_core::provider_bridge::ToolResult {
|
||||
call_id: "call-admission".to_owned(),
|
||||
operation_id: "issues.read".to_owned(),
|
||||
result: json!({"id":"issue-1"}),
|
||||
is_error: false,
|
||||
};
|
||||
if oversized {
|
||||
let error = session
|
||||
.poll_event(Duration::from_secs(1))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("256 KiB admission limit"), "{error}");
|
||||
assert!(!session.state().has_pending_tools());
|
||||
assert!(session.state().pending_tool("call-admission").is_none());
|
||||
// Rejection cannot be bypassed by supplying a matching callback result.
|
||||
assert!(session.deliver_tool_result(&result).is_err());
|
||||
} else {
|
||||
let events = session.poll_event(Duration::from_secs(1)).unwrap().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(session.state().pending_tool("call-admission").is_some());
|
||||
let mut foreign = result.clone();
|
||||
foreign.call_id = "another-call".to_owned();
|
||||
assert!(session.deliver_tool_result(&foreign).is_err());
|
||||
assert!(session.state().pending_tool("call-admission").is_some());
|
||||
session.deliver_tool_result(&result).unwrap();
|
||||
assert!(!session.state().has_pending_tools());
|
||||
assert!(session.deliver_tool_result(&result).is_err());
|
||||
}
|
||||
session.shutdown("admission test complete").unwrap();
|
||||
let rows: Vec<serde_json::Value> = std::fs::read_to_string(&journal)
|
||||
.unwrap()
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str(line).unwrap())
|
||||
.collect();
|
||||
let emitted: Vec<_> = rows.iter().filter_map(|row| row.get("emitted")).collect();
|
||||
assert_eq!(emitted.len(), 1);
|
||||
let frame = emitted[0];
|
||||
assert_eq!(frame["eventType"], "runtime.tool_called");
|
||||
let payload_bytes = serde_json::to_vec(&frame["payload"]).unwrap().len();
|
||||
let frame_bytes = serde_json::to_vec(frame).unwrap().len() + 1;
|
||||
assert!(
|
||||
frame_bytes < 1024 * 1024,
|
||||
"must reach payload admission, not the line limit"
|
||||
);
|
||||
assert_eq!(payload_bytes > 256 * 1024, oversized);
|
||||
let resolutions: Vec<_> = rows
|
||||
.iter()
|
||||
.filter_map(|row| row.get("request"))
|
||||
.filter(|request| request["command"] == "tool.resolve")
|
||||
.collect();
|
||||
if oversized {
|
||||
assert!(
|
||||
resolutions.is_empty(),
|
||||
"rejected event must never send tool.resolve"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(resolutions.len(), 1);
|
||||
assert_eq!(
|
||||
resolutions[0]["params"],
|
||||
json!({
|
||||
"callId":"call-admission","turnId":"turn-admission",
|
||||
"result":{"id":"issue-1"},"error":null,
|
||||
})
|
||||
);
|
||||
}
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receiver_rejects_sub_megabyte_tool_event_before_pending_admission_or_resolution() {
|
||||
check_tool_receiver_admission(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receiver_admits_normal_tool_event_and_resolves_only_correlated_call_once() {
|
||||
check_tool_receiver_admission(false);
|
||||
}
|
||||
@@ -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:d56589c43437277b527ed61155de7d882e3ae5316d67502d8b98a0d435f69f99";
|
||||
const PROFILE_DIGEST = "sha256:48cecd8dc77a5533240fcf2f29d19be05380da4a79f8e5061480f94241db75a8";
|
||||
|
||||
/** 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 } = {}) {
|
||||
|
||||
@@ -563,6 +563,7 @@ describe("native backend factory", () => {
|
||||
["copilot", "../../test/fixtures/copilot-profile-v7-identity.json"],
|
||||
["copilot", "../../test/fixtures/copilot-profile-v9-identity.json"],
|
||||
["copilot", "../../test/fixtures/copilot-profile-v10-identity.json"],
|
||||
["copilot", "../../test/fixtures/copilot-profile-v11-identity.json"],
|
||||
["pi", "../../test-fixtures/pi-acp/profile-v9-identity.json"],
|
||||
] as const)("rejects the exact historical %s identity before runtime startup", (agent, path) => {
|
||||
const historical = JSON.parse(readFileSync(new URL(path, import.meta.url), "utf8"));
|
||||
|
||||
@@ -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, ACPX_SIDECAR_MAX_FRAME_BYTES, stringifyAcpxSidecarFrame } from "../drivers/acpx/sidecar-protocol.js";
|
||||
import { ACPX_SIDECAR_PROTOCOL_VERSION, ACPX_SIDECAR_MAX_FRAME_BYTES, stringifyAcpxSidecarFrame, parseAcpxSidecarRequest, record, text } 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";
|
||||
@@ -23,6 +23,7 @@ import { createCursorToolEvidence } from "../drivers/acpx/cursor-tool-evidence.j
|
||||
import { validateAcpxRichEvent } from "../drivers/acpx/profile-extensions.js";
|
||||
import {
|
||||
awaitSidecarCleanupWithin,
|
||||
boundedIdentity,
|
||||
closeActiveSidecarHostWithin,
|
||||
closeSidecarHostForCommand,
|
||||
combineSidecarAdmissionCleanups,
|
||||
@@ -251,7 +252,7 @@ describe("qualified ACPX runtime sidecar", () => {
|
||||
await vi.waitFor(() => expect(tools.has("3")).toBe(true));
|
||||
expect(emitted).toEqual([{ callId: "3", operationId: "paperclip_finish", input: validated.result }]);
|
||||
// The receipt hashes the actual emitted input, not a second normalization.
|
||||
tools.get("3").settle({ accepted: true });
|
||||
await loadActualToolCommands(tools).resolve({ callId: "3", turnId: "test-turn", result: { accepted: true }, error: null });
|
||||
}
|
||||
const body = await (await response).json();
|
||||
const receipt = readNativeSemanticReceipt({ contents: body.result.content });
|
||||
@@ -320,13 +321,70 @@ describe("qualified ACPX runtime sidecar", () => {
|
||||
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(commit).not.toHaveBeenCalled(); 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 loadActualToolCommands(tools).resolve({ callId: "3", turnId: "test-turn", result: { accepted: true }, error: null });
|
||||
await expect(pending).resolves.toEqual({ accepted: true });
|
||||
expect(commit).toHaveBeenCalledOnce();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
it.each(["success", "accepted-false", "error", "cancel", "timeout", "receiver-rejected"])(
|
||||
"commits normalized delivery only after the actual tool.resolve handler succeeds: %s",
|
||||
async mode => {
|
||||
const raw = { reportedWorkDisposition: "done", summary: "Complete", evidence: [{ ref: mode === "receiver-rejected" ? "x".repeat(300 * 1024) : "fixture" }], verification: [],
|
||||
completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [], remainingWork: [] } };
|
||||
const validation = validatePrpStructuredRunResult(raw);
|
||||
if (!validation.ok) throw new Error("Invalid fixture");
|
||||
const wire = loadActualSidecarWriter(), tools = new Map<string, unknown>(), emitted: unknown[] = [], receipts: unknown[] = [];
|
||||
const commands = loadActualToolCommands(tools);
|
||||
const handler = loadWaitForTool({ tools, emitted, validate: validatePrpStructuredRunResult, emit: wire.emit });
|
||||
const bridge = await startRunnerToolBridge({ handler, timeoutMs: 500, captureSemanticReceipt: () => receipt => receipts.push(receipt) });
|
||||
const headers = { Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json" };
|
||||
const params = { callId: "3", turnId: "test-turn", result: { accepted: mode !== "accepted-false" }, error: null };
|
||||
try {
|
||||
const response = fetch(bridge.url, { method: "POST", headers,
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "paperclip_finish", arguments: raw } }) });
|
||||
await vi.waitFor(() => expect(tools.has("3")).toBe(true), { interval: 5, timeout: 400 });
|
||||
expect(wire.writes).toHaveLength(1);
|
||||
expect(JSON.parse(wire.writes[0]!).payload.input).toEqual(validation.result);
|
||||
expect(receipts).toEqual([]);
|
||||
// These all enter the actual parsed command handler. None may consume
|
||||
// the pending call or attest delivery for a different request/turn.
|
||||
await expect(commands.resolve({ ...params, callId: "missing" })).rejects.toThrow("stale or unknown");
|
||||
await expect(commands.resolve({ ...params, turnId: "foreign" })).rejects.toThrow("stale or unknown");
|
||||
commands.setTurn("foreign");
|
||||
await expect(commands.resolve(params)).rejects.toThrow("stale or unknown");
|
||||
commands.setTurn("test-turn");
|
||||
expect(tools.has("3")).toBe(true);
|
||||
if (mode === "success" || mode === "accepted-false") {
|
||||
await expect(commands.resolve(params)).resolves.toEqual({ resolved: true });
|
||||
} else if (mode === "error") {
|
||||
await expect(commands.resolve({ ...params, result: undefined, error: { message: "Receiver rejected completion" } })).resolves.toEqual({ resolved: true });
|
||||
} else if (mode === "cancel") {
|
||||
const cancelled = await fetch(bridge.url, { method: "POST", headers,
|
||||
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: 3 } }) });
|
||||
expect(cancelled.status).toBe(202);
|
||||
} else if (mode === "receiver-rejected") {
|
||||
// Actual writer acceptance is not receiver admission. This frame is
|
||||
// over Rust's payload bound; no correlated resolution arrives.
|
||||
expect(Buffer.byteLength(JSON.stringify(JSON.parse(wire.writes[0]!).payload))).toBeGreaterThan(256 * 1024);
|
||||
expect(Buffer.byteLength(wire.writes[0]!)).toBeLessThan(ACPX_SIDECAR_MAX_FRAME_BYTES);
|
||||
}
|
||||
const body = await (await response).json();
|
||||
const delivered = mode === "success" || mode === "accepted-false";
|
||||
const receipt = readNativeSemanticReceipt({ contents: body.result.content });
|
||||
expect(receipt).toMatchObject({ normalizedInputSha256: delivered ? semanticInputSha256(validation.result) : null,
|
||||
inputSha256: semanticInputSha256(raw), outcome: delivered ? "returned" : "error" });
|
||||
expect(receipts).toEqual([receipt]);
|
||||
expect(tools.size).toBe(0);
|
||||
await expect(commands.resolve(params)).rejects.toThrow("stale or unknown");
|
||||
expect(receipts).toEqual([receipt]);
|
||||
} finally { await bridge.close(); }
|
||||
},
|
||||
);
|
||||
|
||||
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: [] } };
|
||||
@@ -1180,3 +1238,24 @@ function loadActualSidecarWriter(mode: "normal" | "backpressure" | "throws" = "n
|
||||
`)(process, stringifyAcpxSidecarFrame, ACPX_SIDECAR_PROTOCOL_VERSION, ACPX_SIDECAR_MAX_FRAME_BYTES);
|
||||
return { ...writer, writes, errors };
|
||||
}
|
||||
|
||||
/** Actual parsed command dispatcher; only the owned pending map/turn are supplied. */
|
||||
function loadActualToolCommands(tools: Map<string, unknown>): {
|
||||
resolve: (params: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||
setTurn: (turnId: string | null) => void;
|
||||
} {
|
||||
const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8");
|
||||
const start = source.indexOf("async function dispatch("), end = source.indexOf("\nasync function pumpTurn(", start);
|
||||
const safeStart = source.indexOf("function safeText("), safeEnd = source.indexOf("\nfunction safeMessage(", safeStart);
|
||||
if (start < 0 || end < 0 || safeStart < 0 || safeEnd < 0) throw new Error("Actual sidecar command dispatcher not found");
|
||||
const dispatcher = new Function("tools", "boundedIdentity", "record", "text", "parseAcpxSidecarRequest", "ACPX_SIDECAR_PROTOCOL_VERSION", `
|
||||
let turnId="test-turn", requestId=0;
|
||||
${stripTypeScriptTypes(source.slice(safeStart, safeEnd))}
|
||||
${stripTypeScriptTypes(source.slice(start, end))}
|
||||
return {
|
||||
resolve: params => dispatch(parseAcpxSidecarRequest({ protocolVersion: ACPX_SIDECAR_PROTOCOL_VERSION, id: ++requestId, command: "tool.resolve", params })),
|
||||
setTurn: value => { turnId=value; }
|
||||
};
|
||||
`)(tools, boundedIdentity, record, text, parseAcpxSidecarRequest, ACPX_SIDECAR_PROTOCOL_VERSION);
|
||||
return dispatcher;
|
||||
}
|
||||
@@ -737,7 +737,6 @@ 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 = () => {
|
||||
const pending = tools.get(callId);
|
||||
@@ -748,7 +747,13 @@ async function waitForTool(call: RunnerToolCall): Promise<unknown> {
|
||||
call.signal.addEventListener("abort", abort, { once: true });
|
||||
tools.set(callId, {
|
||||
turnId: activeTurnId,
|
||||
settle,
|
||||
settle: (result) => {
|
||||
// A pipe write alone does not prove receiver admission. Only this
|
||||
// call's turn-bound tool.resolve success confirms runnerd accepted
|
||||
// the validated body; rejection, cancellation and timeout stay null.
|
||||
commitNormalizedInput?.();
|
||||
settle(result);
|
||||
},
|
||||
reject,
|
||||
cleanup: () => call.signal.removeEventListener("abort", abort),
|
||||
});
|
||||
|
||||
@@ -296,7 +296,7 @@ describe("NativeExecutionInputV1", () => {
|
||||
})).toThrow("eventExpiryDays");
|
||||
});
|
||||
|
||||
it.each([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] as const)("accepts only a closed ACPX profile matching the driver and agent at profile version %s", (agentProfileVersion) => {
|
||||
it.each([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as const)("accepts only a closed ACPX profile matching the driver and agent at profile version %s", (agentProfileVersion) => {
|
||||
const provider = {
|
||||
kind: "acpx",
|
||||
agent: "pi",
|
||||
@@ -328,7 +328,7 @@ describe("NativeExecutionInputV1", () => {
|
||||
profile: provider.profile,
|
||||
});
|
||||
expect(parseNativeExecutionInput(parsed)).toEqual(parsed);
|
||||
for (const unsupportedVersion of [0, 12, 1.5, "11", null]) {
|
||||
for (const unsupportedVersion of [0, 13, 1.5, "12", null]) {
|
||||
expect(() => parseNativeExecutionInput({
|
||||
...input,
|
||||
session: { ...input.session, driverKind: "acpx_runtime" },
|
||||
|
||||
@@ -91,7 +91,7 @@ export interface NativeAcpxProfileSnapshot {
|
||||
protocolVersion: 1;
|
||||
acpxVersion: "0.13.1";
|
||||
agent: NativeAcpxAgent;
|
||||
agentProfileVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11;
|
||||
agentProfileVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||
agentServerPackage: string;
|
||||
agentServerVersion: string;
|
||||
agentRuntimePackage: string | null;
|
||||
@@ -608,7 +608,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
|
||||
|| profile.protocolVersion !== 1
|
||||
|| profile.acpxVersion !== "0.13.1"
|
||||
|| profile.agent !== provider.agent
|
||||
|| (profile.agentProfileVersion !== 1 && profile.agentProfileVersion !== 2 && profile.agentProfileVersion !== 3 && profile.agentProfileVersion !== 4 && profile.agentProfileVersion !== 5 && profile.agentProfileVersion !== 6 && profile.agentProfileVersion !== 7 && profile.agentProfileVersion !== 8 && profile.agentProfileVersion !== 9 && profile.agentProfileVersion !== 10 && profile.agentProfileVersion !== 11)
|
||||
|| (profile.agentProfileVersion !== 1 && profile.agentProfileVersion !== 2 && profile.agentProfileVersion !== 3 && profile.agentProfileVersion !== 4 && profile.agentProfileVersion !== 5 && profile.agentProfileVersion !== 6 && profile.agentProfileVersion !== 7 && profile.agentProfileVersion !== 8 && profile.agentProfileVersion !== 9 && profile.agentProfileVersion !== 10 && profile.agentProfileVersion !== 11 && profile.agentProfileVersion !== 12)
|
||||
) {
|
||||
throw new NativeExecutionInputError("input.provider.profile does not match the qualified ACPX v1 profile");
|
||||
}
|
||||
|
||||
@@ -59,13 +59,14 @@ async function permissionPolicyClosure(extraClassifierImport = false): Promise<S
|
||||
vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: vi.fn() }));
|
||||
|
||||
describe("Copilot build-owned installation", () => {
|
||||
it("admits the current v11 declaration and binds its source policy, receipts and patch hashes", () => {
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v11-identity.json", import.meta.url), "utf8"));
|
||||
it("admits the current v12 declaration and binds its source policy, receipts and patch hashes", () => {
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v12-identity.json", import.meta.url), "utf8"));
|
||||
expect(identity.declaration.systemInstructionDelivery).toBe(COPILOT_SYSTEM_INSTRUCTION_DELIVERY);
|
||||
expect(identity.declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1");
|
||||
expect(identity.declaration.permissionContextContract).toBe(COPILOT_PERMISSION_CONTEXT_CONTRACT);
|
||||
expect(identity.declaration.semanticToolReceiptContract).toBe("paperclip.semantic_tool_receipt.v2");
|
||||
expect(identity.declaration.semanticNormalizedInputContract).toBe("validated-forwarded-input-commit-v1");
|
||||
expect(identity.declaration.semanticSidecarReceiptCommitContract).toBe("correlated-tool-resolve-v1");
|
||||
expect(identity.declaration.messageIdentityContract).toBe("copilot-native-message-id-v1");
|
||||
expect(identity.declaration.ownedDistributionDelivery).toBe("COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1");
|
||||
const inventory = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-message-identity-distributions-1.0.88.json", import.meta.url), "utf8"));
|
||||
@@ -90,7 +91,7 @@ describe("Copilot build-owned installation", () => {
|
||||
expect([...closure].filter(path => !bound.has(path))).toEqual(["negative-control:unbound-policy"]);
|
||||
});
|
||||
it.each([...PERMISSION_POLICY_SOURCES, ...SEMANTIC_RECEIPT_SOURCES])("changing %s changes the candidate identity", (_path, field) => {
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v11-identity.json", import.meta.url), "utf8"));
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v12-identity.json", import.meta.url), "utf8"));
|
||||
identity.declaration[field] = "0".repeat(64);
|
||||
const sorted = Object.fromEntries(Object.entries(identity.declaration).sort(([a], [b]) => a.localeCompare(b)));
|
||||
expect(`sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`).not.toBe(identity.commandDigest);
|
||||
|
||||
@@ -72,10 +72,10 @@ export const QUALIFIED_ACPX_PROFILES: Readonly<
|
||||
},
|
||||
copilot: {
|
||||
driverKind: ACPX_DRIVER_KIND, protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION,
|
||||
acpxVersion: QUALIFIED_ACPX_VERSION, agent: "copilot", agentProfileVersion: 11,
|
||||
acpxVersion: QUALIFIED_ACPX_VERSION, agent: "copilot", agentProfileVersion: 12,
|
||||
agentServerPackage: "@github/copilot", agentServerVersion: "1.0.88",
|
||||
agentRuntimePackage: null, agentRuntimeVersion: null,
|
||||
commandDigest: "sha256:d56589c43437277b527ed61155de7d882e3ae5316d67502d8b98a0d435f69f99",
|
||||
commandDigest: "sha256:48cecd8dc77a5533240fcf2f29d19be05380da4a79f8e5061480f94241db75a8",
|
||||
// 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",
|
||||
|
||||
@@ -34,6 +34,7 @@ describe("ACPX recovery identity", () => {
|
||||
["copilot", "../../../test/fixtures/copilot-profile-v8-identity.json"],
|
||||
["copilot", "../../../test/fixtures/copilot-profile-v9-identity.json"],
|
||||
["copilot", "../../../test/fixtures/copilot-profile-v10-identity.json"],
|
||||
["copilot", "../../../test/fixtures/copilot-profile-v11-identity.json"],
|
||||
["pi", "../../../test-fixtures/pi-acp/profile-v9-identity.json"],
|
||||
] as const)("rejects retained %s sessions after the execution identity changes", async (agent, path) => {
|
||||
const fixture = await recoveryFixture();
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"commandDigest": "sha256:48cecd8dc77a5533240fcf2f29d19be05380da4a79f8e5061480f94241db75a8",
|
||||
"declaration": {
|
||||
"schema": "paperclip.acpx_profile_declaration.v1",
|
||||
"agent": "copilot",
|
||||
"agentProfileVersion": 12,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"protocolPolicyRevision": "copilot-agent-manual-v1",
|
||||
"acpxPatchSha256": "bd5393058a218040d217fa85449d59a6f30507de54cd645bf0ef21422823f85e",
|
||||
"policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3",
|
||||
"distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107",
|
||||
"agentFilesBinding": "registered-runtime-context-v1",
|
||||
"systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1",
|
||||
"sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1",
|
||||
"innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a",
|
||||
"upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0",
|
||||
"patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679",
|
||||
"messageIdentityContract": "copilot-native-message-id-v1",
|
||||
"ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1",
|
||||
"permissionContextContract": "copilot-edit-permission-context-v1",
|
||||
"permissionContextSourceSha256": "fe1bb611bea289d81b1c7c973fbb01b93de133a7caa8fe78b7ff4ee1f485b224",
|
||||
"permissionAdapterSourceSha256": "49421a779c76e286ce65d75052026eab9630b1995af9ccf045a2d43d284633ce",
|
||||
"permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9",
|
||||
"permissionRedactionSourceSha256": "687270d7bbe3dccce6c2fed8664e76cda724b43e545cb153b9f54b018de52927",
|
||||
"permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4",
|
||||
"permissionIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1",
|
||||
"semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2",
|
||||
"semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791",
|
||||
"semanticBridgeSourceSha256": "8a43ebafb7a85dd1714ed6945670472666dcb63e8af32b4a74644163fa0e370d",
|
||||
"toolEvidenceSourceSha256": "4b517eed447615b3cc820af348ccc360978175149caa0bbc838d1f78332abe75",
|
||||
"semanticNormalizedInputContract": "validated-forwarded-input-commit-v1",
|
||||
"semanticSidecarSourceSha256": "d58076d59ec23fe2261cba3aa0dca3e9f189105f0a5fa7ca3eeaac05cb338f31",
|
||||
"semanticDirectDriverSourceSha256": "31e36917ee3592fa6d8f4b86632129fdffae20c45aed2e0e367ccf523e064f88",
|
||||
"semanticValidationSourceSha256": "039fca52266fe21ba74ca64660d21228314d8ed737b19ba67941bfb25086e4d5",
|
||||
"semanticNormalizationSourceSha256": "b1ce59251bf9c671f66e0e5173646190b9a2d1bfc5bdb7fb123824213986b92b",
|
||||
"semanticCompletionContractSourceSha256": "00a684f9aea87f68eabf0974681c52461bc308631a1252a89c4ed087d7158b28",
|
||||
"semanticValidatorsSourceSha256": "950b49b233d8dd62ff858398becfc55f0a181160cb8a434d9221b5c17816867c",
|
||||
"semanticSchemaBundleSourceSha256": "bb55e18c5563bf5ec226e301c3399229304f295e986865e96554f193535368f2",
|
||||
"semanticSidecarProtocolSourceSha256": "165cad76f224d60f0dc7928736d47a16787eaa8ab9e4c2222488da5fc8ad632e",
|
||||
"semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user