diff --git a/tests/runner-e2e/copilot-local-fixtures.ts b/tests/runner-e2e/copilot-local-fixtures.ts index e82ed05fcf..b33e3125e6 100644 --- a/tests/runner-e2e/copilot-local-fixtures.ts +++ b/tests/runner-e2e/copilot-local-fixtures.ts @@ -127,6 +127,11 @@ function ownedProcessCommand(pid: number): readonly string[] { * Native onSpawn publishes the runnerd child. Bind its current argv to this run * and per_turn lifecycle before treating its retirement as run cleanup. */ +/** Losing a captured process identity cannot be repaired by a later empty sample. */ +export function retainRunProcessIdentity(previous: T, current: T): T { + return { ...current, identityChanged: previous.identityChanged === true || current.identityChanged === true }; +} + export function observeRunProcesses() { const owned = new Map(); let root: ProcessIdentity | undefined; let providerDeathDispatched = false; let authorityCheck: { authority: RunProcessAuthority; observed: ProcessIdentity; commandSha256: string; accepted: boolean } | undefined; diff --git a/tests/runner-e2e/copilot-protection-flow.test.ts b/tests/runner-e2e/copilot-protection-flow.test.ts index e1623255b1..7b0f32aa0c 100644 --- a/tests/runner-e2e/copilot-protection-flow.test.ts +++ b/tests/runner-e2e/copilot-protection-flow.test.ts @@ -8,7 +8,7 @@ import { createCopilotToolEvidence } from "../../packages/paperclip-runner/src/d import { validateAcpxRichEvent } from "../../packages/paperclip-runner/src/drivers/acpx/profile-extensions.js"; import { copilotOrigin, readCopilotToolEvidence } from "./copilot-evidence.js"; import { copilotProtectionCases, gradeCopilotAttachedSettlement, gradeCopilotDeniedWrite } from "./copilot-protection-cases.js"; -import { createAttachedCommandFixture, createDeniedTargetFixture, bindDeniedTargetPrompt, watchDeniedTarget, exists, isPerTurnRunProcess } from "./copilot-local-fixtures.js"; +import { createAttachedCommandFixture, createDeniedTargetFixture, bindDeniedTargetPrompt, watchDeniedTarget, exists, isPerTurnRunProcess, retainRunProcessIdentity } from "./copilot-local-fixtures.js"; import { runnerMatrix, suiteDefinitionHash } from "./catalog.js"; import { selectRunnerExecutions, parseRunnerSelectors } from "./selectors.js"; @@ -138,6 +138,13 @@ describe("Copilot Product protection integration", () => { expect(isPerTurnRunProcess({ ...authority, groupId: 101 }, observed, args)).toBe(false); expect(isPerTurnRunProcess(authority, { ...observed, start: new Date(1_700_000_001_000).toString() }, args)).toBe(false); }); + it("retains lost process identity after a later empty retirement sample", () => { + let sample = { captured: true, live: [100], identityChanged: false }; + sample = retainRunProcessIdentity(sample, { captured: true, live: [], identityChanged: true }); + sample = retainRunProcessIdentity(sample, { captured: true, live: [], identityChanged: false }); + expect(sample).toEqual({ captured: true, live: [], identityChanged: true }); + expect(retainRunProcessIdentity({ captured: true, live: [100] }, { captured: true, live: [] }).identityChanged).toBe(false); + }); it("reaps a real finite child before its provider-style client can exit", async () => { const root = await mkdtemp("/tmp/pc-copilot-command-"); const fixture = await createAttachedCommandFixture(join(root, "marker"), 150); try { diff --git a/tests/runner-e2e/copilot-protection-flow.ts b/tests/runner-e2e/copilot-protection-flow.ts index cfcffb60f0..aa2bd4f5a6 100644 --- a/tests/runner-e2e/copilot-protection-flow.ts +++ b/tests/runner-e2e/copilot-protection-flow.ts @@ -9,7 +9,7 @@ import { createTaskThroughUi } from "./user-actions.js"; import { approveCopilotContextThroughUi, prepareCopilotContext } from "./copilot-context-permission.js"; import { onlyCopilotAttachedOperations, readCopilotSemanticCompletion } from "./copilot-semantic-evidence.js"; import { copilotOrigin, readCopilotToolEvidence, type CopilotToolNotice } from "./copilot-evidence.js"; -import { createAttachedCommandFixture, createDeniedTargetFixture, bindDeniedTargetPrompt, exists, observeRunProcesses } from "./copilot-local-fixtures.js"; +import { createAttachedCommandFixture, createDeniedTargetFixture, bindDeniedTargetPrompt, exists, observeRunProcesses, retainRunProcessIdentity } from "./copilot-local-fixtures.js"; import { gradeCopilotAttachedSettlement, gradeCopilotDeniedWrite, type CopilotDeniedWriteEvidence } from "./copilot-protection-cases.js"; import { observeCopilotPreStop, type CopilotPreStopObservation, readCopilotDeniedEdit, copilotDenialSampleCursor, readCopilotDenialSettlement, observeCopilotFixtureCommand, readCopilotRemoteMarkerAfterRetirement, prepareCopilotRemoteAction, assertCopilotRemoteRetirement, assertCopilotRemoteAttached, copilotRemoteDeniedSample, copilotActionNotices, type CopilotRemoteBootstrap, type CopilotRemoteFixture, type CopilotRemoteSnapshot, countCopilotToolOrigins, countCopilotEditOriginsForTarget } from "./copilot-protection-evidence.js"; import type { LiveFixtureValues } from "./live-fixtures.js"; @@ -99,7 +99,7 @@ export async function runCopilotProtectionFlow(input: { const checks: Check[] = []; let issue: Row = {}, runs: Row[] = [], runEvents: Row[] = []; let notices: CopilotToolNotice[] = []; const processObserver = remote ? undefined : observeRunProcesses(); - let processes: { captured: boolean; live: number[] } = processObserver?.sample() ?? { captured: false, live: [] }; + let processes: { captured: boolean; live: number[]; identityChanged?: boolean } = processObserver?.sample() ?? { captured: false, live: [] }; let remoteFixture: CopilotRemoteFixture | undefined, baseline: CopilotRemoteSnapshot | undefined, sealed: CopilotRemoteSnapshot | undefined; let remoteCommand: { command: string; commandSha256: string } | undefined; const remoteMarker = `${randomBytes(24).toString("hex")}\n`; @@ -138,7 +138,10 @@ export async function runCopilotProtectionFlow(input: { runEvents = runs[0] ? await collectRunEvents((afterSeq, limit) => api.get(`/api/heartbeat-runs/${runs[0]!.id}/events?afterSeq=${afterSeq}&limit=${limit}`)) : []; notices = runs[0] ? readCopilotToolEvidence(runEvents, runs[0].id) : []; const run = runs[0]; - if (processObserver) processes = processObserver.sample(run?.processPid ? { pid: run.processPid, groupId: run.processGroupId, startedAt: run.processStartedAt, runId: run.id } : undefined); + if (processObserver) { + processes = retainRunProcessIdentity(processes, processObserver.sample(run?.processPid ? { pid: run.processPid, groupId: run.processGroupId, startedAt: run.processStartedAt, runId: run.id } : undefined)); + if (processes.identityChanged) check("owned-process-identity", false, "A captured process identity changed; cleanup cannot be qualified"); + } return { issue, runs, runEvents, notices, processes }; } const wait = (label: string, accept: (state: Awaited>) => boolean) => pollUntil({ label, deadlineAt: input.deadlineAt, load, accept, intervalMs: 200, diff --git a/tests/runner-e2e/copilot-provider-death.test.ts b/tests/runner-e2e/copilot-provider-death.test.ts index e2d86de39c..1982ba2c1d 100644 --- a/tests/runner-e2e/copilot-provider-death.test.ts +++ b/tests/runner-e2e/copilot-provider-death.test.ts @@ -46,6 +46,17 @@ describe("provider-death independent evidence", () => { it("accepts callback expiry and an unfinished failed task with no replay", () => { expect(assertCopilotProviderDeath(facts())).toMatchObject({ expired: true, mutationReplay: false }); }); + it.each(["execute", "read", "edit"])("refuses an additional %s operation after provider death", operation => { + const replay = facts(); + const extra = row(4, "provider.notice.recorded", { schema: "paperclip.provider.notice.v1", scope: "turn", category: "copilot_tool_evidence_v1", provenance: { sessionId: "native-session", turnId: "turn", eventType: "tool", method: "session/update" }, details: Object.entries({ stage: "tool", toolCallId: "extra-tool", operation, status: "completed" }).map(([name, value]) => ({ name, value })) }); + replay.events.push(extra); + expect(() => assertCopilotProviderDeath(replay)).toThrow(/replayed or completed an operation/); + }); + it("refuses an additional semantic operation without a file mutation", () => { + const replay = facts(); + replay.events.push(row(4, "provider.notice.recorded", { schema: "paperclip.provider.notice.v1", scope: "turn", category: "copilot_tool_evidence_v1", provenance: { sessionId: "native-session", turnId: "turn", eventType: "tool", method: "session/update" }, details: Object.entries({ stage: "tool", toolCallId: "semantic-extra", status: "completed", semanticOperationId: "issue.get", semanticCallIdentitySha256: "a".repeat(64), semanticInputSha256: "b".repeat(64), semanticNormalizedInputSha256: "null", semanticResultSha256: "c".repeat(64), semanticOutcome: "returned" }).map(([name, value]) => ({ name, value })) })); + expect(() => assertCopilotProviderDeath(replay)).toThrow(/replayed or completed an operation/); + }); it("refuses missing expiry, stale resolution, success and foreign evidence", () => { const missing = facts(); missing.events.splice(1, 1); expect(() => assertCopilotProviderDeath(missing)).toThrow(); const resolved = facts(); resolved.events.push(row(4, "runtime_request.resolved", { requestId: "request" })); expect(() => assertCopilotProviderDeath(resolved)).toThrow(); diff --git a/tests/runner-e2e/copilot-provider-death.ts b/tests/runner-e2e/copilot-provider-death.ts index f946d03c5c..49464ffb52 100644 --- a/tests/runner-e2e/copilot-provider-death.ts +++ b/tests/runner-e2e/copilot-provider-death.ts @@ -61,7 +61,9 @@ export function assertCopilotProviderDeath(input: { pending: ActiveStopPending; if (pending.scope.requireContextRead && !target) throw new Error("Provider death lacks the exact mutation origin"); const context = target ? readCopilotContextRead(input.events, target, pending.scope.requireContextRead) : undefined; const notices = context ? allNotices.filter(n => ![context.toolCallId, ...context.discoveries.map(d => d.toolCallId)].includes(n.toolCallId)) : allNotices; - if (notices.filter(n => n.stage === "permission_requested").length !== 1 || notices.some(n => n.stage === "permission_delivered" && ["allow_once", "allow_always"].includes(n.outcome ?? "")) || notices.some(n => n.stage === "tool" && n.operation === "edit" && (n.toolCallId !== pending.toolCallId || n.status === "completed"))) throw new Error("Provider death replayed or completed the mutation"); + if (notices.filter(n => n.stage === "permission_requested").length !== 1 || notices.some(n => n.stage === "permission_delivered" && ["allow_once", "allow_always"].includes(n.outcome ?? "")) || notices.some(n => n.stage === "tool" && (n.toolCallId !== pending.toolCallId || n.operation !== "edit" || n.status === "completed"))) throw new Error("Provider death replayed or completed an operation"); + const expirySeq = rows.find(row => row.payload.prpEvent === expiry[0])!.seq; + if (allNotices.some(n => n.stage === "tool" && n.seq >= expirySeq && !(n.toolCallId === pending.toolCallId && n.operation === "edit" && n.status === "failed"))) throw new Error("Provider death executed an operation after callback expiry"); if (!events.some(event => ["turn.failed", "turn.interrupted"].includes(event.eventType) && event.turnId === pending.turnId)) throw new Error("Provider death lacks a failed provider turn"); return { schema: "paperclip.e2e.copilot-provider-death.v1", runId: run.id, requestId: pending.requestId, expired: true, mutationReplay: false }; }