mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
Add Copilot Product qualification and fixture ownership checks
Reviewed partition of the current-master Copilot integration. Complete v30 live qualification is retained; current v31 recovery compatibility, CI and review are tracked explicitly. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
fcbb80554e
commit
c48b89618f
74 files changed
+13325
-58
No files matched your search
@@ -133,7 +133,9 @@ canonical Plan revision, capture its pending UI, approve in the browser, and
|
||||
prove exactly two successful runs. `warm_three_turn` provides exactly two
|
||||
browser follow-up messages, preserves one project/execution-workspace scope,
|
||||
verifies host file contents after every turn, and finishes within three
|
||||
ten-minute turn deadlines. The ordinary warm fixture uses managed instructions,
|
||||
ten-minute turn deadlines. Warm prompts define each newline as the actual LF
|
||||
byte 10 and require byte verification independent of the write expression;
|
||||
literal backslash-plus-n remains a failing output. The ordinary warm fixture uses managed instructions,
|
||||
updates AGENT_HOME each turn, and verifies memory, an unchanged 8 MiB binary and
|
||||
a deletion through public file APIs. Native turns 2 and 3 must copy/hash only the
|
||||
changed memory file, with a saved receipt and the same provider PID. Journal and
|
||||
@@ -144,7 +146,12 @@ Native turns 1 and 2 include an actionable human review in the completion report
|
||||
Every selected case runs in its own isolated Paperclip process, and independent
|
||||
cases may run concurrently. Follow-up turns inside one case retain their shared
|
||||
task state. Each case creates and tears down its own company, secrets,
|
||||
environment selection, agent, and browser-created task. The current plan case
|
||||
environment selection, agent, and browser-created task. Strict Copilot command,
|
||||
denial, pending-permission Stop and provider-death fixtures create an explicit
|
||||
title through the production search composer and bind the task to the public
|
||||
browser creation response ID. Automatic prompt-only naming must not add a
|
||||
native semantic operation or break task correlation. Command, permission,
|
||||
receipt, ordering and cleanup assertions remain unchanged. The current plan case
|
||||
proves three runs on the same issue: publish a two-step Plan,
|
||||
request a three-step revision through the UI, and accept the exact new revision
|
||||
through the UI before verifying implementation and Done.
|
||||
|
||||
@@ -22,6 +22,10 @@ scheduled execution gets
|
||||
a fresh Paperclip home, embedded Postgres database, instance configuration,
|
||||
port, workspace, company, encrypted secrets, environment, and agent.
|
||||
|
||||
`pnpm test:e2e:runner:unit` runs the native-completion source gates with
|
||||
Node's test runner, then the Product support tests with Vitest. A Node test
|
||||
file must not be collected as an empty Vitest suite.
|
||||
|
||||
## Native connection guidance (explicit only)
|
||||
|
||||
The manual-only native-connection-guidance suite separates connection-policy
|
||||
@@ -1461,6 +1465,24 @@ immutable Daytona image; no private hooks or fixture database writes are used.
|
||||
|
||||
## Extended ACP harnesses (explicit only)
|
||||
|
||||
Copilot protection qualification requires a successful `get_task_context` read
|
||||
before the tested native action. The v3 completion oracle independently joins
|
||||
its read-only native lifecycle, authoritative result receipt, and canonical tool
|
||||
events. It then validates the single finish receipt, accepted result, attached
|
||||
command exit, and cleanup. The retained v15 context/completion fixture preserves
|
||||
the original failed grade; its context receipt cannot stand in for completion.
|
||||
Extra semantic actions and missing, late, failed, or mutating context reads fail.
|
||||
The authored pending-permission Stop and provider-death prompts require the
|
||||
already-exposed dedicated `get_task_context` tool directly before the native
|
||||
edit. `search_api` discovers HTTP routes rather than semantic tools; `call_api`
|
||||
and a context-like HTTP response cannot replace the dedicated context receipt.
|
||||
Historical bounded discovery proof remains attested separately and cannot
|
||||
establish context access or completion.
|
||||
The denied-write prompt uses that same dedicated-context guidance.
|
||||
The provider-death final UI assertion accepts the current Blocked label with
|
||||
its blocker-attention explanation. The API settlement still requires exact
|
||||
`blocked`, one failed run, no mutation replay, stale-answer refusal, and cleanup.
|
||||
|
||||
`--suite extended-harnesses` declares 30 Product E2E cells: Cursor, Copilot,
|
||||
and Pi on local and Daytona, each exercising authenticated completion,
|
||||
question/answer continuation, revision-bound semantic plan approval, restart
|
||||
|
||||
@@ -104,6 +104,16 @@ describe("runner E2E catalog", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("specifies real LF bytes in every managed warm turn", () => {
|
||||
const prompts = [daytonaWarmContinuityTask.buildPrompt("nonce"), ...daytonaWarmContinuityTask.buildFollowupMessages!("nonce")];
|
||||
for (const prompt of prompts) {
|
||||
expect(prompt).toContain("actual LF character (byte 10)");
|
||||
expect(prompt).toContain("independently of the string expression used to write them");
|
||||
}
|
||||
expect(prompts[2]).toContain("T1-nonce | T2-nonce");
|
||||
expect(prompts[2]).not.toContain("T1-nonce\\nT2-nonce");
|
||||
});
|
||||
|
||||
it("keeps paid public MCP journeys explicit and fingerprints the shipped workflow instructions", () => {
|
||||
const suite = runnerSuites.find(suite => suite.id === "public-mcp")!;
|
||||
expect(suite.manualOnly).toBe(true);
|
||||
@@ -146,10 +156,10 @@ describe("runner E2E catalog", () => {
|
||||
expect(localIntegrityTasks).toHaveLength(2);
|
||||
expect(openRouterBreadthTasks).toHaveLength(3);
|
||||
expect(runnerSuites.map((suite) => suite.expectedMatrixSize)).toEqual([
|
||||
63, 12, 6, 8, 2, 2, 2, 30, 3, 16, 16, 2, 6, 8, 46, 23, 15, 52, 6, 6, 20, 26, 52, 28, 18, 2, 6, 6, 12, 10, 48, 16, 10, 2, 1, 1, 116,
|
||||
63, 12, 6, 8, 4, 2, 4, 2, 4, 30, 3, 16, 16, 2, 6, 8, 46, 23, 15, 52, 6, 6, 20, 26, 52, 28, 18, 2, 6, 6, 12, 10, 48, 16, 10, 2, 1, 1, 116,
|
||||
]);
|
||||
expect(validateRunnerCatalog()).toHaveLength(698);
|
||||
expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(698);
|
||||
expect(validateRunnerCatalog()).toHaveLength(708);
|
||||
expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(708);
|
||||
expect(
|
||||
runnerMatrix.filter((entry) => entry.suite.id === "core-compatibility"),
|
||||
).toHaveLength(48);
|
||||
|
||||
@@ -6,6 +6,8 @@ import { chatConfirmationTasks } from "./chat-cases.js";
|
||||
import { buildConnectionSuite } from "./connection-cases.js";
|
||||
import { hiringTemplateTasks, hiringTemplateProfile, hiringTemplateDefinitionDigest } from "./hiring-template-cases.js";
|
||||
import { nativeActiveStopTasks } from "./native-active-stop-tasks.js";
|
||||
import { copilotProviderDeathTasks } from "./copilot-active-stop-tasks.js";
|
||||
import { copilotProtectionTasks } from "./copilot-protection-tasks.js";
|
||||
import { cursorNativeTasks } from "./cursor-native-cases.js";
|
||||
import { instructionPersistenceTask } from "./instruction-persistence.js";
|
||||
import { apiResponseReadingTask } from "./api-response-reading.js";
|
||||
@@ -945,7 +947,8 @@ function warmTurnInstructions(turn: 1 | 2 | 3, nonce: string) {
|
||||
`This is warm Daytona continuity turn ${turn} of 3. Work only in the current execution workspace.`,
|
||||
turn === 1
|
||||
? `Create ${file} with exactly this one line followed by a newline: ${lines[0]}`
|
||||
: `Before changing anything, read ${file} and verify its content is exactly ${lines.slice(0, -1).join("\\n")} followed by a newline. Then append exactly ${lines.at(-1)} followed by a newline.`,
|
||||
: `Before changing anything, read ${file} and verify its content is exactly these prior lines in order, each followed by a newline: ${lines.slice(0, -1).join(" | ")}. Then append exactly ${lines.at(-1)} followed by a newline.`,
|
||||
"Each newline in this task means the actual LF character (byte 10), including in personal memory files. An escaped representation containing a literal backslash and n is not a newline. Verify the saved bytes against the requested line content and LF byte 10, independently of the string expression used to write them.",
|
||||
`After the write, verify ${file} contains exactly these lines, once each and in order: ${lines.join(" | ")}.`,
|
||||
`In a native runner, use the current turn completion revision and criterion IDs (CURRENT_TURN_REVISION below is a placeholder, not a literal), then call paperclip_finish exactly once with {reportedWorkDisposition:"${finalTurn ? "done" : "needs_review"}",summary:"${marker}",completionClaim:{contractRevision:CURRENT_TURN_REVISION,objectiveSatisfied:true,criteria:[{criterionId:"objective",status:"satisfied",evidenceRefs:[]}],remainingWork:[]},attentionRequests:${JSON.stringify(reviewRequests)},evidence:[],verification:[{commandOrCheck:"read ${file}",status:"passed"}]}. Wait for that tool call to succeed, then emit exactly ${marker} once as the complete user-facing final response.`,
|
||||
legacyCompletion,
|
||||
@@ -1141,10 +1144,24 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [
|
||||
},
|
||||
{
|
||||
id: "native-active-stop", label: "Stop an unanswered native permission", manualOnly: true,
|
||||
description: "Stop while one exact Cursor native permission remains unanswered; require cancelled provider settlement, caller-owned acknowledgement, stale-answer refusal and independent retirement/no effects.",
|
||||
groups: ["native"], profiles: extendedHarnessProfiles.filter(profile => profile.qualificationCandidate === "cursor"),
|
||||
environments: runnerEnvironments, tasks: nativeActiveStopTasks, expectedMatrixSize: 2,
|
||||
definitionMetadata: { version: 4, qualification: "pending", scheduling: "explicit-only", evidence: "paperclip.e2e.native-active-stop-settlement.v2", pendingObservation: "retained-api-before-caller-uuid-stop", normalCompletionAccepted: false, permissionPolicy: "approve-reads", lifecycle: "per_turn", remoteEvidence: "paperclip.e2e.native-active-stop-remote-retirement.v1", remoteObservationCoverage: "continuous-through-owned-process-retirement", filesystemAfterRemoteRetirementObserved: false, localObservationCoverage: "four-phases-through-cleanup", providerDeath: "not-covered" },
|
||||
description: "Stop while one exact Cursor or Copilot native permission remains unanswered; require cancelled provider settlement, caller-owned acknowledgement, stale-answer refusal and independent retirement/no effects.",
|
||||
groups: ["native"], profiles: extendedHarnessProfiles.filter(profile => ["cursor", "copilot"].includes(profile.qualificationCandidate ?? "")),
|
||||
environments: runnerEnvironments, tasks: nativeActiveStopTasks, expectedMatrixSize: 4,
|
||||
definitionMetadata: { version: 19, copilotDefinition: { version: 18, titleCreation: "explicit-search-composer", taskBinding: "browser-creation-response-id", processAuthority: "local-native-root-or-attested-remote-root", remoteTerminalDiagnostics: "bounded-incomplete-reasons-with-strict-failure", remoteSetupOrder: "observed-action-published-before-context-approval", remoteBootstrapContext: "already-exposed-dedicated-tool-no-http-substitution", bootstrapPrompt: "already-exposed-dedicated-context-before-native-edit", editOrigin: "exact-permission-target-and-native-call-identity", stopAcknowledgement: "retained-current-mainline-api-response", contextPermission: "context-and-bounded-attested-discovery-sequence", permissionCard: "current-operation-and-decision-control-excludes-history", qualification: "pending", scheduling: "explicit-only", evidence: "paperclip.e2e.native-active-stop-settlement.v3", pendingObservation: "retained-api-before-stop-with-fixture-correlation", normalCompletionAccepted: false, permissionPolicy: "approve-reads", lifecycle: "per_turn", remoteEvidence: "paperclip.e2e.native-active-stop-remote-retirement.v1", remoteObservationCoverage: "continuous-through-owned-process-retirement", filesystemAfterRemoteRetirementObserved: false, localObservationCoverage: "four-phases-through-cleanup", providerDeath: "not-covered" }, qualification: "pending", scheduling: "explicit-only", evidence: "paperclip.e2e.native-active-stop-settlement.v2", pendingObservation: "retained-api-before-caller-uuid-stop", normalCompletionAccepted: false, permissionPolicy: "approve-reads", lifecycle: "per_turn", remoteEvidence: "paperclip.e2e.native-active-stop-remote-retirement.v1", remoteObservationCoverage: "continuous-through-owned-process-retirement", filesystemAfterRemoteRetirementObserved: false, localObservationCoverage: "four-phases-through-cleanup", providerDeath: "not-covered" },
|
||||
},
|
||||
{
|
||||
id: "copilot-provider-death", label: "Copilot provider death", manualOnly: true,
|
||||
description: "Terminate only the verified owned Copilot child with an unanswered permission; require callback expiry, stale-answer refusal, no replay and independent retirement.",
|
||||
groups: ["native"], profiles: extendedHarnessProfiles.filter(profile => profile.qualificationCandidate === "copilot"),
|
||||
environments: runnerEnvironments, tasks: copilotProviderDeathTasks, expectedMatrixSize: 2,
|
||||
definitionMetadata: { version: 16, titleCreation: "explicit-search-composer", taskBinding: "browser-creation-response-id", linuxDeathExecutable: "verified-inherited-descriptor-and-live-executable-inode", processAuthority: "local-native-root-or-attested-remote-root", remoteTerminalDiagnostics: "bounded-incomplete-reasons-with-strict-failure", remoteSetupOrder: "observed-action-published-before-context-approval", remoteBootstrapContext: "already-exposed-dedicated-tool-no-http-substitution", finalUiStatus: "blocked-with-current-mainline-attention-label", bootstrapPrompt: "already-exposed-dedicated-context-before-native-edit", editOrigin: "exact-permission-target-and-native-call-identity", contextPermission: "context-and-bounded-attested-discovery-sequence", permissionCard: "current-operation-and-decision-control-excludes-history", qualification: "pending", scheduling: "explicit-only", evidence: "paperclip.e2e.copilot-provider-death.v1", signal: "SIGKILL", target: "owned-native-child-only", permissionPolicy: "approve-reads", lifecycle: "per_turn", automaticRetries: 0 },
|
||||
},
|
||||
{
|
||||
id: "copilot-protection", label: "Copilot native protection", manualOnly: true,
|
||||
description: "Exact native denial with independently correlated provider settlement and acknowledged run Stop, plus attached command settlement with independent process evidence.",
|
||||
groups: ["native"], profiles: extendedHarnessProfiles.filter(profile => profile.qualificationCandidate === "copilot"),
|
||||
environments: runnerEnvironments, tasks: copilotProtectionTasks, expectedMatrixSize: 4,
|
||||
definitionMetadata: { version: 20, titleCreation: "explicit-search-composer", taskBinding: "browser-creation-response-id", remoteTerminalDiagnostics: "bounded-incomplete-reasons-with-strict-failure", remoteSetupOrder: "observed-action-published-before-context-approval", remoteBootstrapContext: "already-exposed-dedicated-tool-no-http-substitution", denialContext: "already-exposed-dedicated-tool-before-write", stopAcknowledgement: "retained-current-mainline-api-response", contextPermission: "context-and-bounded-attested-discovery-sequence", permissionCard: "current-operation-and-decision-control-excludes-history", semanticCompletionEvidence: "paperclip.e2e.copilot-semantic-completion.v4", qualification: "pending", naturalSettlementObservationMs: 2000, scheduling: "explicit-only", evidence: "copilot_tool_evidence_v1", profileVersion: QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion, denialTerminal: "correlated-provider-settlement-and-audited-run-stop", denialSettlementEvidence: "paperclip.e2e.copilot-denial-settlement.v4", activeTurnCancellation: "not-implied-by-completed-provider-turn", settlement: "attached-finite-command-only", settlementMarker: "private-diagnostic-not-deliverable", remoteEvidence: "owned-lease-sealed-observer" },
|
||||
},
|
||||
{
|
||||
id: "native-provider-loss", label: "Lose a runtime with an unanswered native permission", manualOnly: true,
|
||||
@@ -1164,16 +1181,16 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [
|
||||
},
|
||||
{
|
||||
id: "rich-acp-warm-continuity", label: "Rich ACP warm continuity", manualOnly: true,
|
||||
description: "Three browser-driven turns with stable native session, runner process and workspace identity for Cursor.",
|
||||
description: "Three browser-driven turns with stable native session, runner process and workspace identity for Cursor and Copilot.",
|
||||
groups: ["native", "warm"],
|
||||
profiles: extendedHarnessProfiles.filter(profile => profile.qualificationCandidate === "cursor").map(profile => ({ ...profile, buildAgent(input: AgentFixtureBuildInput) {
|
||||
profiles: extendedHarnessProfiles.filter(profile => ["cursor", "copilot"].includes(profile.qualificationCandidate ?? "")).map(profile => ({ ...profile, buildAgent(input: AgentFixtureBuildInput) {
|
||||
const agent = profile.buildAgent(input);
|
||||
return { ...agent, adapterConfig: { ...agent.adapterConfig as Record<string, unknown>, lifecycleMode: "warm", idleTimeoutMs: 300_000 } };
|
||||
} })),
|
||||
environments: [localEnvironment, daytonaWarmEnvironment],
|
||||
tasks: [{ ...daytonaWarmContinuityTask, turnTimeoutMs: 120_000, attemptTimeoutMs: { local: 420_000, daytona: 420_000 } }],
|
||||
expectedMatrixSize: 2,
|
||||
definitionMetadata: { version: 1, qualification: "pending", scheduling: "explicit-only", identity: "native-session-runner-provider-session-process-start" },
|
||||
expectedMatrixSize: 4,
|
||||
definitionMetadata: { version: 3, savedTokenLifecycle: "fenced-session-and-owned-agent-files", leaseEvidence: "daytona-only", qualification: "pending", scheduling: "explicit-only", identity: "native-session-runner-provider-session-process-start" },
|
||||
},
|
||||
{
|
||||
id: "extended-harnesses", label: "Extended ACP harnesses", manualOnly: true,
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { assertCopilotStopAcknowledgement, type CopilotStopAcknowledgement } from "./copilot-stop-acknowledgement.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { canonicalJson } from "../../packages/shared/src/portability-hash.js";
|
||||
import { hasAcpxNativeOrigin } from "./acpx-native-origin.js";
|
||||
import { isValidNativePrpEnvelope } from "./native-event-envelope.js";
|
||||
import { assertCopilotRemoteRetirement, copilotRemoteDeniedSample, copilotActionNotices, type CopilotRemoteSnapshot } from "./copilot-protection-evidence.js";
|
||||
import { readCopilotContextRead } from "./copilot-context-evidence.js";
|
||||
import { copilotEditOriginForPermission, readCopilotToolEvidence } from "./copilot-evidence.js";
|
||||
import { bootstrapReadExecutionId, withoutProvenBootstrapReads, type BootstrapReadProof } from "./native-bootstrap-read-proof.js";
|
||||
|
||||
type Row = Record<string, any>;
|
||||
export interface ActiveStopCaller { type: "board"; userId: "local-board"; source: "local_implicit" }
|
||||
export type ActiveStopProvider = "copilot";
|
||||
export interface ActiveStopScope { provider: ActiveStopProvider; companyId: string; issueId: string; runId: string; target: string; commandSha256?: string; requireContextRead?: boolean }
|
||||
export interface ActiveStopPending {
|
||||
schema: "paperclip.e2e.native-active-stop-pending.v2";
|
||||
caller: ActiveStopCaller; scope: ActiveStopScope; requestId: string; toolCallId: string; nativeSessionId: string; turnId: string;
|
||||
normalizedSessionId: string; sourceInstanceId: string; requestSourceSeq: number; permissionSourceSeq: number;
|
||||
requestRowSha256: string; permissionRowSha256: string; cancellationRequestId: string; observedMonotonicNs: string;
|
||||
toolOriginSourceSeq: number; toolStartedSourceSeq: number; toolOriginRowSha256: string; toolStartedRowSha256: string;
|
||||
}
|
||||
const rec = (v: unknown): Row => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Row : {};
|
||||
const id = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(v) && !v.includes("[REDACTED]");
|
||||
const uuid = (v: unknown): v is string => typeof v === "string" && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/u.test(v);
|
||||
const hash = (v: unknown) => `sha256:${createHash("sha256").update(canonicalJson(v)).digest("hex")}`;
|
||||
function fail(condition: unknown, reason: string): asserts condition { if (!condition) throw new Error(`Native active Stop: ${reason}`); }
|
||||
/** The isolated Product server uses local-trusted board authentication. Bind
|
||||
* the same public session observed by the API client, never infer a null actor. */
|
||||
export function readActiveStopCaller(health: unknown, authentication: unknown): ActiveStopCaller {
|
||||
const session = rec(rec(authentication).session);
|
||||
fail(rec(health).deploymentMode === "local_trusted" && session.userId === "local-board"
|
||||
&& session.id === "paperclip:local_implicit:local-board", "expected isolated board caller missing");
|
||||
return { type: "board", userId: "local-board", source: "local_implicit" };
|
||||
}
|
||||
function isActiveStopCaller(caller: unknown): caller is ActiveStopCaller {
|
||||
const value = rec(caller);
|
||||
return value.type === "board" && value.userId === "local-board" && value.source === "local_implicit";
|
||||
}
|
||||
function canonicalRows(events: readonly unknown[], scope: ActiveStopScope) {
|
||||
fail(events.length <= 20_000 && events.length > 0, "bounded durable evidence required");
|
||||
const rows = events.map(rec).filter(row => rec(row.payload).prpEvent !== undefined);
|
||||
const seen = new Set<number>(), source = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const e = rec(rec(row.payload).prpEvent);
|
||||
fail(row.companyId === scope.companyId && row.runId === scope.runId && isValidNativePrpEnvelope(e, row.protocolSchemaVersion)
|
||||
&& e.sourceKind === "runner" && e.runId === scope.runId
|
||||
&& e.eventType === row.eventType && Number.isSafeInteger(row.seq) && row.seq > 0 && !seen.has(row.seq)
|
||||
&& id(e.sourceInstanceId) && Number.isSafeInteger(e.sourceSeq) && e.sourceSeq > 0
|
||||
&& e.sourceEventId === `${e.sourceInstanceId}:${e.runId}:${e.sourceSeq}` && !source.has(e.sourceEventId), "invalid/duplicate/foreign canonical row");
|
||||
seen.add(row.seq); source.add(e.sourceEventId);
|
||||
}
|
||||
const ordered = [...rows].sort((a, b) => a.seq - b.seq);
|
||||
const lastBySource = new Map<string, number>();
|
||||
for (const row of ordered) {
|
||||
const e = rec(rec(row.payload).prpEvent), previous = lastBySource.get(e.sourceInstanceId) ?? 0;
|
||||
fail(e.sourceSeq > previous, "durable source sequence reordered");
|
||||
lastBySource.set(e.sourceInstanceId, e.sourceSeq);
|
||||
}
|
||||
return ordered.map(row => ({ row, event: rec(rec(row.payload).prpEvent) }));
|
||||
}
|
||||
const terminals = new Set(["turn.completed", "turn.failed", "turn.cancelled", "turn.interrupted"]);
|
||||
const closures = new Set(["runtime_request.resolved", "runtime_request.cancelled", "runtime_request.expired"]);
|
||||
function origin(events: readonly unknown[], scope: ActiveStopScope, bootstrap?: BootstrapReadProof) {
|
||||
fail([scope.companyId, scope.issueId, scope.runId, scope.target].every(id) && scope.provider === "copilot", "exact scope required");
|
||||
const rows = canonicalRows(events, scope);
|
||||
const notices = readCopilotToolEvidence(events, scope.runId);
|
||||
const target = copilotEditOriginForPermission(notices, scope.target);
|
||||
const context = target ? readCopilotContextRead(events, target, scope.requireContextRead) : undefined;
|
||||
const requests = notices.filter(n => n.stage === "permission_requested" && ![context?.toolCallId, ...(context?.discoveries.map(d => d.toolCallId) ?? [])].includes(n.toolCallId));
|
||||
fail(requests.length === 1, "one native permission required");
|
||||
const notice = requests[0]!;
|
||||
fail(id(notice.requestId) && notice.declineOffered === true && (notice.operation === "edit" && notice.target === scope.target), "exact native operation required");
|
||||
const permission = rows.find(x => x.row.seq === notice.seq);
|
||||
const created = rows.filter(x => x.event.eventType === "runtime_request.created" && ![context?.permissionRequestId, ...(context?.discoveries.map(d => d.permissionRequestId) ?? [])].filter(id).includes(rec(rec(x.event.payload).request).requestId));
|
||||
fail(rows.filter(x => closures.has(x.event.eventType)).every(x => [notice.requestId, context?.permissionRequestId, ...(context?.discoveries.map(d => d.permissionRequestId) ?? [])].filter(id).includes(rec(x.event.payload).requestId)), "request or provider already settled");
|
||||
fail(permission && created.length === 1, "one durable native card required");
|
||||
// Current mainline cards carry the opaque native request ID. A separate
|
||||
// details.toolCallId is optional; when present it must agree. The unique
|
||||
// same-stream permission notice binds requestId to the native tool ID.
|
||||
const request = rec(rec(created[0]!.event.payload).request), p = permission.event;
|
||||
fail(request.schema === "paperclip.runtime_request.v2" && request.type === "permission" && request.requestKind === "permission_approval"
|
||||
&& request.status === "pending" && request.requestId === notice.requestId && request.turnId === notice.turnId
|
||||
&& (rec(request.details).toolCallId === undefined || rec(request.details).toolCallId === notice.toolCallId) && (hasAcpxNativeOrigin(request.origin, "acpx", "session/request_permission") || hasAcpxNativeOrigin(request.origin, scope.provider, "session/request_permission"))
|
||||
&& id(p.normalizedSessionId) && p.turnId === notice.turnId && created[0]!.event.turnId === notice.turnId
|
||||
&& created[0]!.event.normalizedSessionId === p.normalizedSessionId && created[0]!.event.sourceInstanceId === p.sourceInstanceId,
|
||||
"native notice/card identity mismatch");
|
||||
const stream = (event: Row) => event.turnId === notice.turnId && event.normalizedSessionId === p.normalizedSessionId && event.sourceInstanceId === p.sourceInstanceId;
|
||||
fail(rows.filter(x => x.event.turnId != null).every(x => stream(x.event)), "foreign turn/session/source");
|
||||
const tool = notices.find(n => n.stage === "tool" && n.toolCallId === notice.toolCallId);
|
||||
fail(tool, "native operation origin missing");
|
||||
const selected = context ? copilotActionNotices(notices, tool, bootstrap, events) : withoutProvenBootstrapReads(notices, tool, bootstrap);
|
||||
fail(selected.every(n => n.runId === notice.runId && n.sessionId === notice.sessionId && n.turnId === notice.turnId
|
||||
&& n.toolCallId === notice.toolCallId && (n.requestId === undefined || n.requestId === notice.requestId)
|
||||
&& (n.operation === undefined || n.operation === notice.operation)
|
||||
&& (!("target" in n) || n.target === undefined || n.target === scope.target)
|
||||
&& (n.commandSha256 === undefined || n.commandSha256 === scope.commandSha256)
|
||||
&& n.status !== "completed" && (n.stage !== "permission_delivered" || n.outcome === "cancel")), "extra/replayed/answered native operation");
|
||||
const tools = selected.filter(n => n.stage === "tool");
|
||||
fail(tools.filter(n => n.status === "pending").length === 1
|
||||
&& tools.filter(n => n.status === "failed").length <= 1
|
||||
&& tools[0]!.status === "pending"
|
||||
&& selected.filter(n => n.stage === "permission_delivered").length <= 1, "duplicate native operation lifecycle");
|
||||
const executions = rows.filter(x => x.event.eventType.startsWith("tool.execution.") && rec(x.event.payload).executionId === bootstrapReadExecutionId(notice.toolCallId));
|
||||
fail(executions.filter(x => x.event.eventType === "tool.execution.started").length === 1
|
||||
&& executions.filter(x => x.event.eventType === "tool.execution.completed").length <= 1
|
||||
&& executions.every(x => rec(x.event.payload).schema === "paperclip.tool.execution.v1" && rec(x.event.payload).transport === "builtin"
|
||||
&& ["tool.execution.started", "tool.execution.progressed", "tool.execution.completed"].includes(x.event.eventType)
|
||||
&& rec(x.event.payload).status === (x.event.eventType === "tool.execution.completed" ? "failed" : "running")), "duplicate canonical operation lifecycle");
|
||||
// ACP permits a permission request before its tool notification. Require
|
||||
// both in the pre-Stop observation, without imposing a card/start order.
|
||||
const toolOrigin = rows.find(x => x.row.seq === tools[0]!.seq);
|
||||
const toolStarted = executions.find(x => x.event.eventType === "tool.execution.started");
|
||||
fail(toolOrigin && toolStarted, "native operation origin missing");
|
||||
const allowed = new Set(notices.filter(n => n.stage === "tool").map(n => bootstrapReadExecutionId(n.toolCallId)));
|
||||
fail(rows.filter(x => x.event.eventType.startsWith("tool.execution.")).every(x => allowed.has(rec(x.event.payload).executionId)), "unattributed operation");
|
||||
fail(rows.filter(x => x.event.eventType === "tool.execution.completed" && rec(x.event.payload).executionId === bootstrapReadExecutionId(notice.toolCallId))
|
||||
.every(x => rec(x.event.payload).status !== "completed"), "target operation completed");
|
||||
return { rows, notice, permission, created: created[0]!, toolOrigin, toolStarted, request, stream };
|
||||
}
|
||||
export function observeActiveStopPending(input: { events: readonly unknown[]; run: Row; issue: Row; scope: ActiveStopScope; caller: ActiveStopCaller; cancellationRequestId: string; bootstrap?: BootstrapReadProof }): ActiveStopPending {
|
||||
const { run, issue, scope } = input, proof = origin(input.events, scope, input.bootstrap);
|
||||
fail(isActiveStopCaller(input.caller) && uuid(input.cancellationRequestId) && run.id === scope.runId && run.companyId === scope.companyId && run.nativeIssueId === scope.issueId
|
||||
&& run.status === "running" && run.runtimeMode === "native" && issue.id === scope.issueId && issue.companyId === scope.companyId && issue.status === "in_progress"
|
||||
&& run.resultJson?.startupCancellation == null && run.resultJson?.nativeCancellation == null, "run is not fresh active work");
|
||||
fail(!(readCopilotToolEvidence(input.events, scope.runId)).some(n => n.stage === "permission_delivered" && n.requestId === proof.notice.requestId), "permission already answered");
|
||||
fail(!proof.rows.some(x => terminals.has(x.event.eventType) || (closures.has(x.event.eventType) && rec(x.event.payload).requestId === proof.notice.requestId)), "request or provider already settled");
|
||||
fail(!proof.rows.some(x => x.event.eventType === "tool.execution.completed" && rec(x.event.payload).executionId === bootstrapReadExecutionId(proof.notice.toolCallId)), "operation already ended");
|
||||
return { schema: "paperclip.e2e.native-active-stop-pending.v2", scope, caller: input.caller, requestId: proof.notice.requestId!, toolCallId: proof.notice.toolCallId,
|
||||
nativeSessionId: proof.notice.sessionId, turnId: proof.notice.turnId, normalizedSessionId: proof.permission.event.normalizedSessionId,
|
||||
sourceInstanceId: proof.permission.event.sourceInstanceId, requestSourceSeq: proof.created.event.sourceSeq, permissionSourceSeq: proof.permission.event.sourceSeq,
|
||||
requestRowSha256: hash(proof.created.row), permissionRowSha256: hash(proof.permission.row), cancellationRequestId: input.cancellationRequestId,
|
||||
toolOriginSourceSeq: proof.toolOrigin.event.sourceSeq, toolStartedSourceSeq: proof.toolStarted.event.sourceSeq,
|
||||
toolOriginRowSha256: hash(proof.toolOrigin.row), toolStartedRowSha256: hash(proof.toolStarted.row),
|
||||
observedMonotonicNs: process.hrtime.bigint().toString() };
|
||||
}
|
||||
export function readActiveStopSettlement(input: { events: readonly unknown[]; run: Row; issue: Row; pending: ActiveStopPending; dispatchMonotonicNs: string; stopAcknowledgement: CopilotStopAcknowledgement; bootstrap?: BootstrapReadProof }) {
|
||||
const before = input.pending, { run, issue } = input;
|
||||
fail(before?.schema === "paperclip.e2e.native-active-stop-pending.v2" && isActiveStopCaller(before.caller) && uuid(before.cancellationRequestId)
|
||||
&& /^[1-9][0-9]{0,29}$/u.test(before.observedMonotonicNs) && /^[1-9][0-9]{0,29}$/u.test(input.dispatchMonotonicNs)
|
||||
&& BigInt(before.observedMonotonicNs) < BigInt(input.dispatchMonotonicNs), "pre-dispatch pending observation missing");
|
||||
const proof = origin(input.events, before.scope, input.bootstrap);
|
||||
fail(hash(proof.created.row) === before.requestRowSha256 && hash(proof.permission.row) === before.permissionRowSha256
|
||||
&& hash(proof.toolOrigin.row) === before.toolOriginRowSha256 && hash(proof.toolStarted.row) === before.toolStartedRowSha256
|
||||
&& proof.toolOrigin.event.sourceSeq === before.toolOriginSourceSeq && proof.toolStarted.event.sourceSeq === before.toolStartedSourceSeq
|
||||
&& proof.notice.sessionId === before.nativeSessionId && proof.notice.turnId === before.turnId && proof.notice.toolCallId === before.toolCallId
|
||||
&& proof.notice.requestId === before.requestId && proof.permission.event.normalizedSessionId === before.normalizedSessionId
|
||||
&& proof.permission.event.sourceInstanceId === before.sourceInstanceId && proof.created.event.sourceSeq === before.requestSourceSeq
|
||||
&& proof.permission.event.sourceSeq === before.permissionSourceSeq, "observed pending request changed");
|
||||
const closed = proof.rows.filter(x => closures.has(x.event.eventType) && rec(x.event.payload).requestId === before.requestId), terminal = proof.rows.filter(x => terminals.has(x.event.eventType));
|
||||
fail(closed.length === 1 && terminal.length === 1, "one closed request and one terminal required");
|
||||
const c = closed[0]!, t = terminal[0]!, cp = rec(c.event.payload), tp = rec(t.event.payload);
|
||||
// Product persists the harness projection, not the raw runnerd backend
|
||||
// payload. The terminal mapper closes pending permissions with turn_terminal
|
||||
// and emits {status,error}. Bind that exact boundary to the retained native
|
||||
// request and caller-owned Stop below; a generic terminal is insufficient.
|
||||
fail(proof.stream(c.event) && proof.stream(t.event) && c.event.eventType === "runtime_request.cancelled" && cp.requestId === before.requestId && cp.turnId === before.turnId
|
||||
&& cp.requestKind === "permission_approval" && cp.reason === "turn_terminal"
|
||||
&& cp.itemId === proof.request.itemId && cp.action === undefined && cp.response === undefined && cp.replayAllowed !== true
|
||||
&& t.event.eventType === "turn.cancelled" && tp.status === "cancelled" && tp.error === null
|
||||
&& c.event.sourceSeq > Math.max(before.requestSourceSeq, before.permissionSourceSeq, before.toolOriginSourceSeq, before.toolStartedSourceSeq)
|
||||
&& t.event.sourceSeq > c.event.sourceSeq,
|
||||
"pending callback did not close through cancellation");
|
||||
fail(proof.rows.filter(x => x.event.eventType.startsWith("tool.execution.")
|
||||
|| (x.event.eventType === "provider.notice.recorded" && rec(x.event.payload).category === "copilot_tool_evidence_v1"))
|
||||
.every(x => x.event.sourceSeq < t.event.sourceSeq), "native operation after provider cancellation");
|
||||
const stop = rec(rec(run.resultJson).nativeCancellation);
|
||||
fail(run.id === before.scope.runId && run.companyId === before.scope.companyId && run.nativeIssueId === before.scope.issueId && run.runtimeMode === "native" && run.status === "cancelled"
|
||||
&& issue.id === before.scope.issueId && issue.companyId === before.scope.companyId && issue.status === "in_progress"
|
||||
&& stop.schema === "paperclip.native-cancellation.v1" && stop.companyId === before.scope.companyId && stop.runId === run.id && stop.issueId === issue.id
|
||||
&& stop.scope === "run" && stop.dispatched === true && stop.dispatchState === "acknowledged" && stop.reasonCode === "cancellation_run_only"
|
||||
&& Array.isArray(stop.effects) && stop.effects.length === 1 && stop.effects[0] === "release_run_resources"
|
||||
&& id(stop.intentAuditId) && id(stop.acknowledgementAuditId) && stop.intentAuditId !== stop.acknowledgementAuditId,
|
||||
"same-scope caller Stop acknowledgement missing");
|
||||
assertCopilotStopAcknowledgement(input.stopAcknowledgement, run,
|
||||
{ companyId: before.scope.companyId, issueId: before.scope.issueId, runId: before.scope.runId, callerUserId: before.caller.userId }, before.cancellationRequestId, input.dispatchMonotonicNs);
|
||||
return { schema: "paperclip.e2e.native-active-stop-settlement.v3", pending: before, dispatchMonotonicNs: input.dispatchMonotonicNs, stopAcknowledgement: input.stopAcknowledgement,
|
||||
branch: "pending_permission_cancelled" as const, closedRequestSourceSeq: c.event.sourceSeq, terminalSourceSeq: t.event.sourceSeq,
|
||||
closedRequestRowSha256: hash(c.row), terminalRowSha256: hash(t.row), intentId: stop.intentId as string,
|
||||
intentAuditId: stop.intentAuditId as string, acknowledgementAuditId: stop.acknowledgementAuditId as string,
|
||||
taskStillInProgress: true, normalCompletionAccepted: false, replayAllowed: false };
|
||||
}
|
||||
|
||||
export interface ActiveStopRemoteObservation {
|
||||
phase: "before-request" | "pending" | "owned-process-retirement";
|
||||
source: "live-snapshot" | "retirement-seal";
|
||||
snapshot: CopilotRemoteSnapshot;
|
||||
}
|
||||
|
||||
/** A per-turn Daytona observer seals itself when the owned tree retires. The
|
||||
* host may retrieve that receipt later; retrieval is not a fresh observation. */
|
||||
export function readActiveStopRemoteRetirement(input: {
|
||||
scope: Pick<ActiveStopScope, "companyId" | "runId" | "target">; observations: readonly ActiveStopRemoteObservation[];
|
||||
}) {
|
||||
const observations = input.observations;
|
||||
fail(observations.length === 3
|
||||
&& observations.map(o => `${o.phase}:${o.source}`).join(",") === "before-request:live-snapshot,pending:live-snapshot,owned-process-retirement:retirement-seal",
|
||||
"remote seal cannot stand in for a fresh causal sample");
|
||||
const [baseline, pending, terminal] = observations.map(o => o.snapshot) as [CopilotRemoteSnapshot, CopilotRemoteSnapshot, CopilotRemoteSnapshot];
|
||||
fail(baseline.binding.companyId === input.scope.companyId && baseline.binding.runId === input.scope.runId, "remote observation belongs to another run");
|
||||
for (const snapshot of [baseline, pending, terminal]) {
|
||||
fail(!copilotRemoteDeniedSample(snapshot, baseline, input.scope.target, "pending").exists, "remote target changed");
|
||||
}
|
||||
fail(BigInt(baseline.observedMonotonicNs) < BigInt(pending.observedMonotonicNs)
|
||||
&& BigInt(pending.observedMonotonicNs) < BigInt(terminal.observedMonotonicNs), "remote observation reused or out of order");
|
||||
fail(!baseline.setup.published && pending.setup.published && pending.setup.sha256 === terminal.setup.sha256
|
||||
&& baseline.processes.captured && pending.processes.captured
|
||||
&& baseline.processes.live.includes(baseline.processes.root?.pid ?? -1)
|
||||
&& pending.processes.live.includes(pending.processes.root?.pid ?? -1), "remote pending lifetime unproven");
|
||||
assertCopilotRemoteRetirement(terminal, baseline);
|
||||
assertCopilotRemoteRetirement(terminal, pending);
|
||||
for (const snapshot of [baseline, pending]) {
|
||||
fail(snapshot.processes.journal.every(p => terminal.processes.journal.some(q =>
|
||||
p.pid === q.pid && p.startTicks === q.startTicks && p.bootId === q.bootId)), "remote descendant journal lost");
|
||||
}
|
||||
return { schema: "paperclip.e2e.native-active-stop-remote-retirement.v1" as const,
|
||||
coverage: "continuous-through-owned-process-retirement" as const, filesystemAfterRetirementObserved: false as const,
|
||||
binding: terminal.binding, target: input.scope.target,
|
||||
observations: observations.map(o => ({ phase: o.phase, source: o.source,
|
||||
observedMonotonicNs: o.snapshot.observedMonotonicNs, snapshotSha256: hash(o.snapshot) })) };
|
||||
}
|
||||
|
||||
/** Local files remain accessible through teardown. Remote per-turn files do
|
||||
* not: require the complete lifetime seal, never relabel it as a later sample. */
|
||||
export function assertActiveStopRetirement(input: {
|
||||
completed: boolean; identityChanged: boolean; settlement?: "stop" | "provider-death";
|
||||
processes: { captured: boolean; live: readonly number[] };
|
||||
watcher: { complete: boolean; targetMutationCount: number };
|
||||
} & ({ environment: "local"; samples: ReadonlyArray<{ phase: string; absent: boolean }> }
|
||||
| { environment: "daytona"; remote: Parameters<typeof readActiveStopRemoteRetirement>[0] })) {
|
||||
fail(input.completed && !input.identityChanged && input.processes.captured
|
||||
&& input.processes.live.length === 0 && input.watcher.complete && input.watcher.targetMutationCount === 0,
|
||||
"retirement or continuous no-effect proof is incomplete");
|
||||
if (input.environment === "daytona") return readActiveStopRemoteRetirement(input.remote);
|
||||
fail(input.environment === "local" && input.samples.length === 4 && input.samples.every(s => s.absent === true)
|
||||
&& input.samples.map(s => s.phase).join(",") === `before-request,pending,${input.settlement === "provider-death" ? "after-provider-death" : "after-stop"},after-cleanup`,
|
||||
"causal target observation missing or changed");
|
||||
return { coverage: "local-through-cleanup" as const };
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { readCopilotStopAcknowledgement, type CopilotStopAcknowledgement } from "./copilot-stop-acknowledgement.js";
|
||||
import { copilotPendingPermissionCard } from "./copilot-permission-card.js";
|
||||
import { assertCopilotProviderDeath } from "./copilot-provider-death.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { pollUntil, type RunnerApi } from "./api.js";
|
||||
import { collectRunEvents } from "./run-observations.js";
|
||||
import { createTaskThroughUi } from "./user-actions.js";
|
||||
import { approveCopilotContextThroughUi, prepareCopilotContext } from "./copilot-context-permission.js";
|
||||
import { createDeniedTargetFixture, exists, observeRunProcesses } from "./copilot-local-fixtures.js";
|
||||
import { assertCopilotRemoteRetirement, copilotRemoteDeniedSample, prepareCopilotRemoteAction, type CopilotRemoteBootstrap, type CopilotRemoteFixture, type CopilotRemoteSnapshot } from "./copilot-protection-evidence.js";
|
||||
import { assertActiveStopRetirement, readActiveStopCaller, observeActiveStopPending, readActiveStopSettlement, type ActiveStopRemoteObservation, type ActiveStopCaller, type ActiveStopPending, type ActiveStopScope } from "./copilot-active-stop-evidence.js";
|
||||
import type { BootstrapReadProof } from "./native-bootstrap-read-proof.js";
|
||||
import type { LiveFixtureValues } from "./live-fixtures.js";
|
||||
import type { MatrixExecution } from "./types.js";
|
||||
type Row = Record<string, any>;
|
||||
type Check = { id: string; passed: boolean; detail: string };
|
||||
export interface ActiveStopState { run: Row; issue: Row; events: readonly unknown[] }
|
||||
/** Remote runs use the attested sandbox root, never a controller transport PID. */
|
||||
export function localActiveStopProcessAuthority(run: Row | undefined, remote: boolean) {
|
||||
if (remote || !run?.processPid) return undefined;
|
||||
return { pid: run.processPid, groupId: run.processGroupId, startedAt: run.processStartedAt, runId: run.id };
|
||||
}
|
||||
/** The API's Linux proc timestamp can refresh for the same kernel process.
|
||||
* Once admitted, retain the independently observed birth identity instead. */
|
||||
export function activeStopOwnedProcessKey(authority: ReturnType<typeof localActiveStopProcessAuthority>, processes: {
|
||||
captured: boolean; journal?: Array<{ pid: number; bootId?: string; startTicks?: string }>;
|
||||
}): string | undefined {
|
||||
if (!authority || !processes.captured) return undefined;
|
||||
const root = processes.journal?.find(p => p.pid === authority.pid);
|
||||
return JSON.stringify(root?.bootId && root.startTicks
|
||||
? { pid: authority.pid, groupId: authority.groupId, runId: authority.runId, bootId: root.bootId, startTicks: root.startTicks }
|
||||
: authority);
|
||||
}
|
||||
|
||||
/** Retain a real pending API observation before the only mutating request.
|
||||
* Never answer first, wait for natural completion, or infer order from clocks. */
|
||||
export async function stopAtPendingPermission(input: {
|
||||
scope: ActiveStopScope; caller: ActiveStopCaller; bootstrap?: BootstrapReadProof; deadlineAt: number;
|
||||
load(): Promise<ActiveStopState>;
|
||||
retain(receipt: ActiveStopPending): Promise<void>;
|
||||
retainAcknowledgement?(receipt: CopilotStopAcknowledgement): Promise<void>;
|
||||
stop(runId: string, cancellationRequestId: string): Promise<Row>;
|
||||
}) {
|
||||
const cancellationRequestId = randomUUID();
|
||||
const state = await input.load();
|
||||
const pending = observeActiveStopPending({ ...state, scope: input.scope, caller: input.caller, cancellationRequestId, bootstrap: input.bootstrap });
|
||||
await input.retain(pending);
|
||||
const fresh = observeActiveStopPending({ ...await input.load(), scope: input.scope, caller: input.caller, cancellationRequestId, bootstrap: input.bootstrap });
|
||||
if (fresh.requestRowSha256 !== pending.requestRowSha256 || fresh.permissionRowSha256 !== pending.permissionRowSha256
|
||||
|| fresh.toolOriginRowSha256 !== pending.toolOriginRowSha256 || fresh.toolStartedRowSha256 !== pending.toolStartedRowSha256
|
||||
|| Date.now() >= input.deadlineAt) throw new Error("Native active Stop pending boundary changed or expired");
|
||||
const dispatchMonotonicNs = process.hrtime.bigint().toString();
|
||||
const stopped = await input.stop(input.scope.runId, cancellationRequestId);
|
||||
const stopAcknowledgement = readCopilotStopAcknowledgement(stopped,
|
||||
{ companyId: input.scope.companyId, issueId: input.scope.issueId, runId: input.scope.runId, callerUserId: input.caller.userId }, cancellationRequestId, dispatchMonotonicNs);
|
||||
await input.retainAcknowledgement?.(stopAcknowledgement);
|
||||
const label = "cancelled unanswered native permission";
|
||||
const final = await pollUntil({ label, deadlineAt: input.deadlineAt, intervalMs: 200, load: async () => {
|
||||
const value = await input.load();
|
||||
if (["succeeded", "failed", "timed_out"].includes(value.run.status)) throw new Error(`Stopped waiting for ${label}: unexpected run terminal`);
|
||||
return value;
|
||||
}, accept: value => { readActiveStopSettlement({ ...value, pending, dispatchMonotonicNs, stopAcknowledgement, bootstrap: input.bootstrap }); return true; } });
|
||||
return { pending, dispatchMonotonicNs, stopAcknowledgement, settlement: readActiveStopSettlement({ ...final, pending, dispatchMonotonicNs, stopAcknowledgement, bootstrap: input.bootstrap }) };
|
||||
}
|
||||
export async function runNativeActiveStopFlow(input: {
|
||||
page: Page; api: RunnerApi; fixtures: LiveFixtureValues; execution: MatrixExecution; nonce: string; workspacePath: string; deadlineAt: number;
|
||||
remoteBootstrap?: CopilotRemoteBootstrap;
|
||||
registerCleanupAssertion(callback: () => Promise<Check[]>): void;
|
||||
registerBeforeEnvironmentTeardownAssertion(callback: () => Promise<Check[]>): void;
|
||||
observe(issue: Row, runs: Row[]): void;
|
||||
capture(id: string, label: string, file: string): Promise<void>;
|
||||
evidence(name: string, value: unknown): Promise<void>;
|
||||
}) {
|
||||
const { api, page, fixtures, execution, nonce } = input, provider = execution.profile.qualificationCandidate;
|
||||
if ((provider !== "copilot") || !["pending-permission-stop", "pending-permission-provider-death"].includes(execution.task.id)) throw new Error("Unknown native active Stop case");
|
||||
const remote = execution.environment.id === "daytona";
|
||||
const providerDeath = execution.task.id === "pending-permission-provider-death";
|
||||
let deathPending: ActiveStopPending | undefined;
|
||||
let deathSettlement: ReturnType<typeof assertCopilotProviderDeath> | undefined;
|
||||
if ((!remote && execution.environment.id !== "local") || (remote && !input.remoteBootstrap)) throw new Error("Active Stop requires an isolated admitted environment");
|
||||
const checks: Check[] = []; let issue: Row = {}, runs: Row[] = [], events: Row[] = [];
|
||||
const check = (id: string, passed: boolean, detail: string) => { checks.push({ id, passed, detail }); expect(passed, detail).toBe(true); };
|
||||
const name = `active-stop-${nonce}.txt`, local = remote ? undefined : await createDeniedTargetFixture(input.workspacePath, name);
|
||||
const target = local?.targetRelativePath ?? name;
|
||||
const observer = remote ? undefined : observeRunProcesses();
|
||||
let processes: { captured: boolean; live: number[]; identityChanged?: boolean; journal?: Array<{ pid: number; bootId?: string; startTicks?: string }> } = { captured: false, live: [] };
|
||||
let processIdentity: string | undefined, processError = false;
|
||||
const processErrorReasons = new Set<string>();
|
||||
const remoteObservations: ActiveStopRemoteObservation[] = [];
|
||||
let retirement: ReturnType<typeof assertActiveStopRetirement> | undefined;
|
||||
const samples: Array<{ phase: string; absent: boolean }> = [];
|
||||
let fixture: CopilotRemoteFixture | undefined, baseline: CopilotRemoteSnapshot | undefined, sealed: CopilotRemoteSnapshot | undefined;
|
||||
let completed: Awaited<ReturnType<typeof stopAtPendingPermission>> | undefined;
|
||||
const observeProcesses = () => {
|
||||
const authority = localActiveStopProcessAuthority(runs[0], remote);
|
||||
if (observer) processes = observer.sample(authority);
|
||||
const key = activeStopOwnedProcessKey(authority, processes);
|
||||
if (key) { if (processIdentity && processIdentity !== key) { processError = true; processErrorReasons.add("owned-process-key-changed"); } processIdentity ??= key; }
|
||||
if (processes.identityChanged) { processError = true; processErrorReasons.add("observed-process-birth-changed"); }
|
||||
};
|
||||
const load = async (): Promise<ActiveStopState> => {
|
||||
if (issue.id) issue = await api.get<Row>(`/api/issues/${issue.id}`);
|
||||
const list = await api.get<Row[]>(`/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100`);
|
||||
runs = await Promise.all(list.map(r => api.get<Row>(`/api/heartbeat-runs/${r.id}`)));
|
||||
if (runs.length > 1) throw new Error("Native active Stop dispatched an extra run");
|
||||
events = runs[0] ? await collectRunEvents<Row>((afterSeq, limit) => api.get(`/api/heartbeat-runs/${runs[0]!.id}/events?afterSeq=${afterSeq}&limit=${limit}`)) : [];
|
||||
observeProcesses(); input.observe(issue, runs); return { run: runs[0] ?? {}, issue, events };
|
||||
};
|
||||
const sample = async (phase: string) => {
|
||||
let value: boolean;
|
||||
if (remote) {
|
||||
if (!fixture || !baseline) throw new Error("Missing remote active Stop observer");
|
||||
if (phase !== "pending") throw new Error("Remote filesystem phase requires a fresh live snapshot");
|
||||
const snap = await fixture.snapshot(phase);
|
||||
remoteObservations.push({ phase, source: "live-snapshot", snapshot: snap });
|
||||
value = !copilotRemoteDeniedSample(snap, baseline, target, "pending").exists;
|
||||
await input.evidence(`active-stop-${phase}-remote.json`, snap);
|
||||
} else value = !await exists(local!.targetPath);
|
||||
samples.push({ phase, absent: value }); check(`no-effect-${phase}`, value, "Exact target remains absent at the causal observation boundary");
|
||||
};
|
||||
const scope = (): ActiveStopScope => ({ provider, companyId: fixtures.company.id, issueId: issue.id, runId: runs[0]!.id, target, requireContextRead: true });
|
||||
const bootstrap = (): BootstrapReadProof | undefined => fixture ? { actionFile: fixture.actionFile, events } : undefined;
|
||||
const assertSettlement = async () => {
|
||||
const state = await load();
|
||||
if (providerDeath) {
|
||||
if (!deathPending || !deathSettlement) throw new Error("Missing provider-death settlement");
|
||||
return assertCopilotProviderDeath({ ...state, pending: deathPending });
|
||||
}
|
||||
if (!completed) throw new Error("Missing active Stop settlement");
|
||||
return readActiveStopSettlement({ ...state, ...completed, bootstrap: bootstrap() });
|
||||
};
|
||||
const cleanup = async () => {
|
||||
const receipt: Check[] = [];
|
||||
try {
|
||||
await load();
|
||||
if (remote) {
|
||||
if (!fixture || !baseline) throw new Error("Remote observer was never armed");
|
||||
sealed ??= await fixture.finish(); assertCopilotRemoteRetirement(sealed, baseline); processes = sealed.processes;
|
||||
} else if (processes.captured && processes.live.length) {
|
||||
await pollUntil({ label: "active Stop provider retirement", deadlineAt: Date.now() + 5000, intervalMs: 100, load: async () => { observeProcesses(); return processes; }, accept: p => p.live.length === 0 });
|
||||
}
|
||||
if (!remote) await sample("after-cleanup");
|
||||
const watch = remote ? sealed!.watcher : local!.watcher.finish();
|
||||
retirement = assertActiveStopRetirement({ completed: Boolean(completed || deathSettlement), settlement: providerDeath ? "provider-death" : "stop", identityChanged: processError, processes, watcher: watch,
|
||||
...(remote ? { environment: "daytona" as const, remote: { scope: scope(), observations: remoteObservations } } : { environment: "local" as const, samples }) });
|
||||
await assertSettlement();
|
||||
receipt.push({ id: "active-stop-retired-no-effects", passed: true, detail: remote ? "Exact run root/descendants retired with continuous no-effects proof through retirement; later UI/API checks do not claim fresh filesystem observation" : "Exact run root/descendants retired; unanswered target remained absent through cleanup" });
|
||||
return receipt;
|
||||
} finally {
|
||||
if (timer) clearInterval(timer);
|
||||
try {
|
||||
await input.evidence("native-active-stop-cleanup.json", { processes, processError, processErrorReasons: [...processErrorReasons], samples, remoteObservations, retirement, sealed, watcher: local?.watcher.finish(), checks: receipt });
|
||||
} finally {
|
||||
if (remote) await fixture?.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
const timer = observer ? setInterval(() => { try { observeProcesses(); } catch (error) {
|
||||
processError = true;
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
processErrorReasons.add(typeof code === "string" && /^[A-Z_]{1,32}$/u.test(code) ? `observation-${code}` : "process-observation-failed");
|
||||
} }, 100) : undefined;
|
||||
(remote ? input.registerBeforeEnvironmentTeardownAssertion : input.registerCleanupAssertion)(cleanup);
|
||||
const prompt = () => `${execution.task.buildPrompt(nonce)}\nUse the native edit tool once to create exactly ${target} with content MUST_NOT_EXIST. Do not use a shell command.`;
|
||||
try {
|
||||
const caller = readActiveStopCaller(await api.get("/api/health"), await api.get("/api/auth/get-session"));
|
||||
const agent = await api.get<Row>(`/api/agents/${fixtures.agent.id}`);
|
||||
const configured = await api.patch<Row>(`/api/agents/${fixtures.agent.id}`, { adapterConfig: { ...agent.adapterConfig, acpxPermissionMode: "approve-reads", timeoutSec: 120, lifecycleMode: "per_turn" } });
|
||||
check("explicit-per-turn-policy", configured.adapterConfig?.acpxPermissionMode === "approve-reads" && configured.adapterConfig?.lifecycleMode === "per_turn", "Agent mode and per-turn restrictive permission policy selected before startup");
|
||||
const project = await api.post<Row>(`/api/companies/${fixtures.company.id}/projects`, { name: `Native active Stop ${nonce}`, executionWorkspacePolicy: { enabled: true, defaultMode: "shared_workspace", sharedWorkspaceConcurrency: "serialize", allowIssueOverride: false, environmentId: fixtures.environment.id, workspaceStrategy: { type: "project_primary" } }, workspace: { name: "Primary", sourceType: "local_path", cwd: input.workspacePath, isPrimary: true } });
|
||||
if (!remote) await sample("before-request");
|
||||
const createdTask = await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(nonce), prompt: remote ? input.remoteBootstrap!.prompt(nonce) : prompt(), workMode: "standard", projectName: project.name, requireExplicitTitle: true });
|
||||
issue = (await pollUntil({ label: "browser-created active Stop task", deadlineAt: input.deadlineAt, load: async () => (await api.get<Row[]>(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(i => i.id === createdTask.issueId), accept: Boolean }))!;
|
||||
await page.goto(`/${fixtures.company.issuePrefix}/issues/${issue.identifier ?? issue.id}`);
|
||||
await prepareCopilotContext({
|
||||
remoteSetup: remote ? async () => {
|
||||
await pollUntil({ label: "active Stop remote bootstrap", deadlineAt: input.deadlineAt, load, accept: state => state.run.status === "running" });
|
||||
const bound = await input.remoteBootstrap!.bindAndRelease({ issueId: issue.id, runId: runs[0]!.id, targets: [target], actionPrompt: async value => {
|
||||
fixture = value;
|
||||
const prepared = await prepareCopilotRemoteAction({ fixture: value, companyId: fixtures.company.id, environmentId: fixtures.environment.id, runId: runs[0]!.id, target, prompt: prompt() });
|
||||
baseline = prepared.baseline;
|
||||
remoteObservations.push({ phase: "before-request", source: "live-snapshot", snapshot: baseline });
|
||||
await input.evidence("active-stop-before-request-remote.json", baseline); return prepared.prompt;
|
||||
} });
|
||||
if (bound !== fixture) throw new Error("Remote active Stop publication identity changed");
|
||||
} : undefined,
|
||||
approveContext: async () => {
|
||||
await approveCopilotContextThroughUi({ page, companyId: fixtures.company.id, deadlineAt: input.deadlineAt, load, evidence: input.evidence });
|
||||
},
|
||||
});
|
||||
const label = "unanswered native permission";
|
||||
await pollUntil({ label, deadlineAt: input.deadlineAt, intervalMs: 200, load: async () => {
|
||||
const state = await load(); if (["failed", "timed_out", "cancelled", "succeeded"].includes(state.run.status)) throw new Error(`Stopped waiting for ${label}: provider ended without pending callback`); return state;
|
||||
}, accept: state => { observeActiveStopPending({ ...state, scope: scope(), caller, cancellationRequestId: randomUUID(), bootstrap: bootstrap() }); return true; } });
|
||||
await page.goto(`/${fixtures.company.issuePrefix}/issues/${issue.identifier ?? issue.id}`);
|
||||
const card = copilotPendingPermissionCard(page, target);
|
||||
await expect(card).toHaveCount(1); await expect(card).toContainText(target); await expect(card.getByRole("button", { name: "Deny", exact: true })).toBeEnabled();
|
||||
await input.capture("pending-permission", "Native permission left unanswered before Stop", "pending-permission.png"); await sample("pending");
|
||||
if (providerDeath) {
|
||||
deathPending = observeActiveStopPending({ ...await load(), scope: scope(), caller, cancellationRequestId: randomUUID(), bootstrap: bootstrap() });
|
||||
await input.evidence("copilot-provider-death-pending.json", deathPending);
|
||||
const fresh = observeActiveStopPending({ ...await load(), scope: scope(), caller, cancellationRequestId: deathPending.cancellationRequestId, bootstrap: bootstrap() });
|
||||
if (fresh.requestRowSha256 !== deathPending.requestRowSha256 || fresh.toolStartedRowSha256 !== deathPending.toolStartedRowSha256) throw new Error("Provider death pending boundary changed");
|
||||
const dispatch = remote ? await fixture!.killOwnedCopilot() : observer!.killOwnedCopilot();
|
||||
await input.evidence("copilot-provider-death-dispatch.json", dispatch);
|
||||
deathSettlement = await pollUntil({ label: "expired callback after owned Copilot death", deadlineAt: input.deadlineAt, intervalMs: 200, load: async () => {
|
||||
const state = await load();
|
||||
if (["succeeded", "cancelled", "timed_out"].includes(state.run.status)) throw new Error("Provider death produced an unexpected terminal run");
|
||||
return assertCopilotProviderDeath({ ...state, pending: deathPending! });
|
||||
}, accept: Boolean });
|
||||
check("provider-death-expires-pending", true, "The owned provider died and its unanswered callback expired without completing the task");
|
||||
await input.evidence("copilot-provider-death-settlement.json", deathSettlement);
|
||||
} else {
|
||||
completed = await stopAtPendingPermission({ scope: scope(), caller, bootstrap: bootstrap(), deadlineAt: input.deadlineAt, load,
|
||||
retain: proof => input.evidence("native-active-stop-pending.json", proof), retainAcknowledgement: proof => input.evidence("native-active-stop-acknowledgement.json", proof), stop: runId => api.post(`/api/heartbeat-runs/${runId}/cancel`) });
|
||||
check("pending-callback-cancelled", true, "Unanswered native permission closed through a cancelled provider turn and the exact caller-owned Stop acknowledgement");
|
||||
await input.evidence("native-active-stop-settlement.json", completed);
|
||||
}
|
||||
// Only after confirmed cancellation: test the real public stale-response fence.
|
||||
const response = await api.request.post(`/api/heartbeat-runs/${runs[0]!.id}/runtime-requests/${encodeURIComponent((deathPending ?? completed!.pending).requestId)}/resolve`, { data: { turnId: (deathPending ?? completed!.pending).turnId, requestKind: "permission_approval", resolution: { action: "accept" } } });
|
||||
check("stale-answer-refused", response.status() === 409, "Stopped permission rejects a later answer rather than replaying work");
|
||||
await assertSettlement();
|
||||
if (remote) {
|
||||
// finish drains the observer's automatic retirement seal. It does not
|
||||
// extend the remote watch through subsequent host UI/cleanup assertions.
|
||||
sealed = await fixture!.finish(); assertCopilotRemoteRetirement(sealed, baseline!); processes = sealed.processes;
|
||||
remoteObservations.push({ phase: "owned-process-retirement", source: "retirement-seal", snapshot: sealed });
|
||||
await input.evidence("active-stop-owned-process-retirement-remote.json", sealed);
|
||||
} else await sample(providerDeath ? "after-provider-death" : "after-stop");
|
||||
await page.reload();
|
||||
const finalUiTimeout = () => {
|
||||
const remaining = input.deadlineAt - Date.now();
|
||||
if (remaining <= 0) throw new Error("Active Stop final UI deadline elapsed");
|
||||
return Math.min(30_000, remaining);
|
||||
};
|
||||
// Absence of a permission button during React loading is not proof that a
|
||||
// stopped request is unanswerable. First observe the actual task/run UI.
|
||||
await expect(page.getByTestId("issue-detail-header").getByRole("button", {
|
||||
// Mainline includes the live blocker-attention explanation in a
|
||||
// blocked status label. The API settlement still requires exact blocked.
|
||||
name: providerDeath ? /^Change status \(current: Blocked(?: · [^)]+)?\)$/ : "Change status (current: In Progress)", exact: true,
|
||||
})).toBeVisible({ timeout: finalUiTimeout() });
|
||||
await expect(page.getByTestId("task-chat-thread").getByTestId("task-chat-collapsible-marker")
|
||||
.filter({ has: page.getByText(providerDeath ? "Run failed" : "Run cancelled", { exact: true }) }))
|
||||
.toBeVisible({ timeout: finalUiTimeout() });
|
||||
await expect(page.getByTestId("task-chat-history-loading"))
|
||||
.toHaveCount(0, { timeout: finalUiTimeout() });
|
||||
await expect(card.getByRole("button", { name: "Deny", exact: true }))
|
||||
.toHaveCount(0, { timeout: finalUiTimeout() });
|
||||
await assertSettlement();
|
||||
check("one-unfinished-cancelled-run", runs.length === 1 && issue.status === (providerDeath ? "blocked" : "in_progress") && runs[0]!.status === (providerDeath ? "failed" : "cancelled"), "No automatic follow-up run or false task completion");
|
||||
await input.capture("final-state", "Stopped native permission is no longer answerable", "final-state.png");
|
||||
await input.evidence("api-state.json", { issue, run: runs[0], runs, checks, samples, remoteObservations, runEvents: events, runEventsByRun: [{ runId: runs[0]!.id, events }], activeStop: completed, providerDeath: deathSettlement });
|
||||
return { issue, runs, checks };
|
||||
} finally {
|
||||
// Local observation continues through cleanup. Remote cleanup revalidates
|
||||
// the lifetime seal and fresh API state without claiming later file reads.
|
||||
await input.evidence("native-active-stop-checks.json", { issue, runs, checks, samples, remoteObservations, completed, deathPending, deathSettlement });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { RunnerTaskFixture } from "./types.js";
|
||||
export const contextOrientation = "First invoke the dedicated Paperclip get_task_context tool directly and wait for its returned context. It is already available; no discovery is needed. search_api discovers HTTP routes, not semantic tools. Do not use call_api or an HTTP route as a substitute for get_task_context. Then ";
|
||||
export const nativeActiveStopTasks: readonly RunnerTaskFixture[] = [{
|
||||
id: "pending-permission-stop", label: "Stop while native permission is unanswered", groups: [], workMode: "standard", flow: "native_active_stop",
|
||||
expectedRunCount: 1, attemptTimeoutMs: { local: 300_000, daytona: 300_000 }, turnTimeoutMs: 120_000,
|
||||
expectedTerminalState: { issue: "in_progress", run: "cancelled" },
|
||||
buildTitle: nonce => `Native pending permission Stop ${nonce}`,
|
||||
buildVisibleMarker: nonce => `NATIVE-ACTIVE-STOP-${nonce}`,
|
||||
buildPrompt: () => contextOrientation + "attempt the supplied exact native operation once. Leave its native permission callback pending for the board operator. Do not answer, reject or cancel your own permission; do not retry, use another tool/path, call paperclip_finish, or claim completion. The operator will stop this unfinished task while permission is still unanswered.",
|
||||
buildMatchers: () => [], // Dedicated flow requires pending API observation, caller-owned Stop and retirement.
|
||||
}];
|
||||
|
||||
export const copilotProviderDeathTasks: readonly RunnerTaskFixture[] = [{
|
||||
id: "pending-permission-provider-death", label: "Copilot dies while permission is unanswered", groups: [], workMode: "standard", flow: "native_active_stop",
|
||||
expectedRunCount: 1, attemptTimeoutMs: { local: 300_000, daytona: 300_000 }, turnTimeoutMs: 120_000,
|
||||
expectedTerminalState: { issue: "blocked", run: "failed" },
|
||||
buildTitle: nonce => `Copilot pending permission provider death ${nonce}`,
|
||||
buildVisibleMarker: nonce => `COPILOT-PROVIDER-DEATH-${nonce}`,
|
||||
buildPrompt: () => contextOrientation + "attempt the supplied exact native edit once. Leave its native permission callback unanswered for the board operator. Do not retry, use another tool/path, call paperclip_finish, or claim completion. The operator will terminate the owned provider process while this unfinished task waits for permission.",
|
||||
buildMatchers: () => [],
|
||||
}];
|
||||
@@ -0,0 +1,484 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { applyMainlineStopMetadata, stopAcknowledgementForTest } from "./copilot-stop-test-fixtures.js";
|
||||
import type { CopilotStopAcknowledgement } from "./copilot-stop-acknowledgement.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { assertActiveStopRetirement, readActiveStopRemoteRetirement, type ActiveStopRemoteObservation, readActiveStopCaller, observeActiveStopPending, readActiveStopSettlement, type ActiveStopProvider, type ActiveStopScope } from "./copilot-active-stop-evidence.js";
|
||||
import { stopAtPendingPermission, localActiveStopProcessAuthority } from "./copilot-active-stop-flow.js";
|
||||
import { runnerMatrix, runnerSuites, suiteDefinitionHash } from "./catalog.js";
|
||||
import { selectRunnerExecutions, parseRunnerSelectors } from "./selectors.js";
|
||||
import { createCopilotToolEvidence } from "../../packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.js";
|
||||
import type { CanonicalProviderEvent } from "../../packages/paperclip-runner/src/provider-events.js";
|
||||
|
||||
import { CodexSessionState } from "../../packages/paperclip-runner/src/drivers/codex/codex-session-state.js";
|
||||
import { mapTerminalTurn } from "../../packages/paperclip-runner/src/drivers/codex/codex-session-terminal.js";
|
||||
|
||||
const caller = readActiveStopCaller({ deploymentMode: "local_trusted" }, { session: { userId: "local-board", id: "paperclip:local_implicit:local-board" } });
|
||||
const cancellationRequestId = "11111111-2222-4333-8444-555555555555";
|
||||
type Row = Record<string, any>;
|
||||
function fixture(provider: ActiveStopProvider = "copilot") {
|
||||
const scope: ActiveStopScope = { provider, companyId: "company", issueId: "issue", runId: "run", target: "target.txt" };
|
||||
const row = (seq: number, eventType: string, payload: Row): Row => ({ companyId: "company", runId: "run", seq, eventType, protocolSchemaVersion: 1,
|
||||
payload: { prpEvent: { schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", eventType, runId: "run", turnId: "turn",
|
||||
normalizedSessionId: "normalized-session", sourceInstanceId: "source", sourceSeq: seq, sourceEventId: `source:run:${seq}`, emittedAt: "2026-01-01T00:00:00Z", payload } } });
|
||||
const notice = (seq: number, stage: string, fields: Row) => row(seq, "provider.notice.recorded", {
|
||||
schema: "paperclip.provider.notice.v1", scope: "turn", category: `${provider}_tool_evidence_v1`,
|
||||
provenance: { sessionId: "native-session", turnId: "turn", eventType: stage, method: stage === "tool" ? "session/update" : "session/request_permission" },
|
||||
details: Object.entries({ stage, toolCallId: "tool", ...fields }).map(([name, value]) => ({ name, value: String(value) })),
|
||||
});
|
||||
const operation = { operation: "edit", target: scope.target };
|
||||
const events: Row[] = [
|
||||
notice(1, "tool", { status: "pending", ...operation }),
|
||||
row(2, "tool.execution.started", { schema: "paperclip.tool.execution.v1", executionId: "tool", transport: "builtin", status: "running", ...operation }),
|
||||
notice(3, "permission_requested", { requestId: "request", declineOffered: true, ...operation }),
|
||||
row(4, "runtime_request.created", { request: { schema: "paperclip.runtime_request.v2", requestKind: "permission_approval", type: "permission", status: "pending", requestId: "request", turnId: "turn", itemId: "item-run", method: "session/request_permission",
|
||||
details: { toolCallId: "tool" }, origin: { adapter: "acpx-runtime-sidecar", provider, method: "session/request_permission" } } }),
|
||||
];
|
||||
const run: Row = { id: "run", companyId: "company", nativeIssueId: "issue", runtimeMode: "native", status: "running", resultJson: {} };
|
||||
const issue = { id: "issue", companyId: "company", status: "in_progress" };
|
||||
let stopAcknowledgement: CopilotStopAcknowledgement;
|
||||
let pendingObservation: ReturnType<typeof observeActiveStopPending> | undefined;
|
||||
const state = () => ({ events, run, issue, stopAcknowledgement });
|
||||
const pending = () => pendingObservation = observeActiveStopPending({ ...state(), scope, caller, cancellationRequestId });
|
||||
const permissionResponse = vi.fn();
|
||||
const settle = (requestId = cancellationRequestId) => {
|
||||
// Exercise the actual Product terminal and pending-request producers. Only
|
||||
// the state storage/emitter and callback are test doubles; no provider runs.
|
||||
const request = events.find(event => event.eventType === "runtime_request.created" && event.payload.prpEvent.payload.request.requestId === "request")!.payload.prpEvent.payload.request;
|
||||
const state = {
|
||||
terminalTurns: new Map(), workspaceChangesByTurn: new Map(), result: null,
|
||||
conversationMode: "direct", activeTurnId: "turn", turnStarted: true,
|
||||
pendingRuntimeRequestMap: new Map([[request.requestId, { request, settle: permissionResponse }]]),
|
||||
cancelPendingRequests: CodexSessionState.prototype.cancelPendingRequests,
|
||||
emit: (eventType: string, value: Row) => events.push(row(Math.max(...events.map(r => r.seq)) + 1, eventType, value)),
|
||||
} as unknown as CodexSessionState;
|
||||
mapTerminalTurn(state, { id: "turn", status: "cancelled", error: null }, "turn");
|
||||
run.status = "cancelled"; run.resultJson = { startupCancellation: { cancellationRequestId: requestId, requestedBy: { type: "board", userId: "local-board" } }, nativeCancellation: {
|
||||
schema: "paperclip.native-cancellation.v1", intentId: `native-cancellation:${requestId}`, companyId: "company", runId: "run", issueId: "issue", scope: "run",
|
||||
dispatched: true, dispatchState: "acknowledged", reasonCode: "cancellation_run_only", effects: ["release_run_resources"], intentAuditId: "audit-intent", acknowledgementAuditId: "audit-ack",
|
||||
} };
|
||||
applyMainlineStopMetadata(run);
|
||||
if (pendingObservation) stopAcknowledgement = stopAcknowledgementForTest(run, requestId, (BigInt(pendingObservation.observedMonotonicNs) + 1n).toString());
|
||||
return run;
|
||||
};
|
||||
return { scope, events, run, issue, row, notice, state, pending, settle, permissionResponse };
|
||||
}
|
||||
const frame = (row: Row) => row.payload.prpEvent;
|
||||
const payload = (row: Row) => frame(row).payload;
|
||||
const arrivalOrders = ["tool-first", "permission-first"] as const;
|
||||
function withArrivalOrder(provider: ActiveStopProvider, order: typeof arrivalOrders[number]) {
|
||||
const f = fixture(provider);
|
||||
if (order === "permission-first") {
|
||||
// Isolate canonical card/start order in these synthetic mutation cases.
|
||||
// Actual provider-specific notice ordering is exercised by the projectors.
|
||||
f.events.unshift(f.events.pop()!);
|
||||
f.events.forEach((row, index) => {
|
||||
row.seq = frame(row).sourceSeq = index + 1;
|
||||
frame(row).sourceEventId = `source:run:${index + 1}`;
|
||||
});
|
||||
}
|
||||
return f;
|
||||
}
|
||||
function withProjectedArrivalOrder(provider: ActiveStopProvider, order: typeof arrivalOrders[number]) {
|
||||
const f = fixture(provider), start = payload(f.events[1]!), card = payload(f.events[3]!);
|
||||
const command = "printf MUST_NOT_EXIST > target.txt";
|
||||
if (provider === "copilot") {
|
||||
f.scope.commandSha256 = `sha256:${createHash("sha256").update(command).digest("hex")}`;
|
||||
start.commandSha256 = f.scope.commandSha256;
|
||||
}
|
||||
f.events.length = 0;
|
||||
const append = (eventType: string, value: Row) => f.events.push(f.row(f.events.length + 1, eventType, value));
|
||||
const projector = createCopilotToolEvidence({
|
||||
sessionId: "native-session", turnId: "turn", workingDirectory: "/fixture", active: () => true,
|
||||
emit: (event: CanonicalProviderEvent) => { append(event.eventType, event.payload); },
|
||||
});
|
||||
const tool = { type: "tool_call", tag: "tool_call", toolCallId: "tool", status: "pending",
|
||||
kind: "edit", rawInput: { fileName: "target.txt" },
|
||||
};
|
||||
let delivered: ((outcome: string) => void) | undefined, beforeTool: Row[] | undefined;
|
||||
const permission = () => {
|
||||
delivered = projector.permission({ raw: { sessionId: "native-session", toolCall: { toolCallId: "tool", kind: "edit", rawInput: tool.rawInput } } }, "request", ["decline", "cancel"]);
|
||||
// Match the sidecar: invoke the evidence projector before publishing the
|
||||
// permission request; project each iterator tool before its canonical row.
|
||||
append("runtime_request.created", card);
|
||||
};
|
||||
const notification = () => { projector.tool(tool); append("tool.execution.started", start); };
|
||||
if (order === "permission-first") { permission(); beforeTool = structuredClone(f.events); notification(); }
|
||||
else { notification(); permission(); }
|
||||
if (!delivered) throw new Error("Native permission projector rejected the fixture");
|
||||
return { ...f, beforeTool, delivered, nativeStageOrder: () => f.events.filter(row => row.eventType === "provider.notice.recorded").map(row => payload(row).provenance.eventType) };
|
||||
}
|
||||
function settled() { const f = fixture(); const pending = f.pending(); f.settle(); return { f, pending, read: () => readActiveStopSettlement({ ...f.state(), pending, dispatchMonotonicNs: (BigInt(pending.observedMonotonicNs) + 1n).toString() }) }; }
|
||||
function withSessionPrefix(provider: ActiveStopProvider = "copilot") {
|
||||
const f = fixture(provider);
|
||||
// The retained failed attempt contained v1 session.started followed by these
|
||||
// v2 session events before its v1 tool/permission events. Keep that shape;
|
||||
// this synthetic fixture supplies the otherwise required native tool proof.
|
||||
for (const row of f.events) {
|
||||
row.seq += 30; frame(row).sourceSeq += 3;
|
||||
frame(row).sourceEventId = `source:run:${frame(row).sourceSeq}`;
|
||||
}
|
||||
const prefix = [f.row(17, "session.started", {}),
|
||||
f.row(19, "session.capabilities.updated", { sessionGoals: null }),
|
||||
f.row(21, "session.goal.snapshot", { goal: null, workingNow: false })];
|
||||
prefix.forEach((row, index) => {
|
||||
const e = frame(row); e.turnId = null; e.sourceSeq = index + 1; e.sourceEventId = `source:run:${index + 1}`;
|
||||
if (index > 0) { row.protocolSchemaVersion = 2; e.schema = "paperclip.prp.event.v2"; e.schemaVersion = 2; }
|
||||
});
|
||||
f.events.unshift(...prefix);
|
||||
return f;
|
||||
}
|
||||
|
||||
describe("definitely active native permission Stop", () => {
|
||||
describe.each(["copilot"] as const)("%s pre-Stop arrival orders", provider => {
|
||||
it.each(arrivalOrders)("accepts %s only after both exact origins exist and retains them through settlement", order => {
|
||||
const f = withProjectedArrivalOrder(provider, order), pending = f.pending();
|
||||
expect(f.nativeStageOrder()).toEqual(provider === "copilot" && order === "permission-first"
|
||||
? ["permission_requested", "tool"] : ["tool", "permission_requested"]);
|
||||
expect(pending.schema).toBe("paperclip.e2e.native-active-stop-pending.v2");
|
||||
expect(pending.toolOriginRowSha256).toMatch(/^sha256:[a-f0-9]{64}$/u);
|
||||
expect(pending.toolStartedRowSha256).toMatch(/^sha256:[a-f0-9]{64}$/u);
|
||||
expect(pending.toolStartedSourceSeq < pending.requestSourceSeq).toBe(order === "tool-first");
|
||||
f.settle();
|
||||
expect(readActiveStopSettlement({ ...f.state(), pending, dispatchMonotonicNs: (BigInt(pending.observedMonotonicNs) + 1n).toString() }))
|
||||
.toMatchObject({ branch: "pending_permission_cancelled", pending });
|
||||
});
|
||||
it("cannot retain permission-only projector output before its exact origin arrives", () => {
|
||||
const f = withProjectedArrivalOrder(provider, "permission-first");
|
||||
expect(() => observeActiveStopPending({ ...f.state(), events: f.beforeTool!, scope: f.scope, caller, cancellationRequestId })).toThrow();
|
||||
});
|
||||
it.each(arrivalOrders)("rejects a delivered answer in real-projector %s evidence", order => {
|
||||
const f = withProjectedArrivalOrder(provider, order); f.delivered("cancel"); expect(f.pending).toThrow("permission already answered");
|
||||
});
|
||||
it("does not accept a permission-first request for a different native origin", () => {
|
||||
const f = withProjectedArrivalOrder(provider, "permission-first");
|
||||
const tool = f.events.find(row => row.eventType === "provider.notice.recorded" && payload(row).provenance.eventType === "tool")!;
|
||||
payload(tool).details.find((d: Row) => d.name === "toolCallId").value = "another-tool";
|
||||
expect(f.pending).toThrow("native operation origin missing");
|
||||
});
|
||||
it.each([
|
||||
["missing native origin", (f: ReturnType<typeof fixture>) => { f.events.splice(1, 1); }],
|
||||
["missing canonical start", (f: ReturnType<typeof fixture>) => { f.events.splice(2, 1); }],
|
||||
["wrong native tool", (f: ReturnType<typeof fixture>) => { payload(f.events[1]!).details.find((d: Row) => d.name === "toolCallId").value = "other"; }],
|
||||
["wrong canonical tool", (f: ReturnType<typeof fixture>) => { payload(f.events[2]!).executionId = "other"; }],
|
||||
["wrong command or path", (f: ReturnType<typeof fixture>) => { payload(f.events[1]!).details.find((d: Row) => d.name === "target").value = "other.txt"; }],
|
||||
["wrong source", (f: ReturnType<typeof fixture>) => { frame(f.events[2]!).sourceInstanceId = "other"; frame(f.events[2]!).sourceEventId = "other:run:3"; }],
|
||||
["wrong turn", (f: ReturnType<typeof fixture>) => { frame(f.events[2]!).turnId = "other"; }],
|
||||
["wrong request", (f: ReturnType<typeof fixture>) => { payload(f.events[0]!).request.requestId = "other"; }],
|
||||
["replayed canonical start", (f: ReturnType<typeof fixture>) => { f.events.push(f.row(5, "tool.execution.started", payload(f.events[2]!))); }],
|
||||
["replayed native origin", (f: ReturnType<typeof fixture>) => { f.events.push(f.row(5, "provider.notice.recorded", payload(f.events[1]!))); }],
|
||||
["answered permission", (f: ReturnType<typeof fixture>) => { f.events.push(f.notice(5, "permission_delivered", { requestId: "request", outcome: "cancel" })); }],
|
||||
["terminal operation", (f: ReturnType<typeof fixture>) => { f.events.push(f.row(5, "tool.execution.completed", { schema: "paperclip.tool.execution.v1", transport: "builtin", executionId: "tool", status: "failed" })); }],
|
||||
] as const)("rejects permission-first %s before Stop", (_label, mutate) => {
|
||||
const f = withArrivalOrder(provider, "permission-first"); mutate(f); expect(f.pending).toThrow();
|
||||
});
|
||||
it("cannot substitute a canonical start first observed after Stop", () => {
|
||||
const f = withArrivalOrder(provider, "permission-first"), pending = f.pending();
|
||||
const started = f.events.splice(2, 1)[0]!;
|
||||
// A late row has a new durable identity even when every operation field
|
||||
// is identical. Final evidence must not backfill the retained receipt.
|
||||
started.seq = frame(started).sourceSeq = 5; frame(started).sourceEventId = "source:run:5";
|
||||
f.events.push(started); f.settle();
|
||||
for (const row of f.events.slice(-2)) {
|
||||
row.seq += 1; frame(row).sourceSeq += 1; frame(row).sourceEventId = `source:run:${row.seq}`;
|
||||
}
|
||||
expect(() => readActiveStopSettlement({ ...f.state(), pending, dispatchMonotonicNs: (BigInt(pending.observedMonotonicNs) + 1n).toString() }))
|
||||
.toThrow("observed pending request changed");
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["copilot"] as const)("accepts the mixed v1/v2 session prefix before strict %s pending proof", provider => {
|
||||
const f = withSessionPrefix(provider);
|
||||
expect(f.pending()).toMatchObject({ requestId: "request", toolCallId: "tool", permissionSourceSeq: 6, requestSourceSeq: 7 });
|
||||
});
|
||||
it.each([
|
||||
["schema/version mismatch", (f: ReturnType<typeof withSessionPrefix>) => { frame(f.events[1]!).schemaVersion = 1; }],
|
||||
["row/envelope mismatch", (f: ReturnType<typeof withSessionPrefix>) => { f.events[1]!.protocolSchemaVersion = 1; }],
|
||||
["unknown schema", (f: ReturnType<typeof withSessionPrefix>) => { frame(f.events[1]!).schema = "paperclip.prp.event.v3"; frame(f.events[1]!).schemaVersion = f.events[1]!.protocolSchemaVersion = 3; }],
|
||||
["foreign session source", (f: ReturnType<typeof withSessionPrefix>) => { frame(f.events[1]!).sourceKind = "provider"; }],
|
||||
] as const)("still rejects %s in a mixed-version stream", (_label, mutate) => {
|
||||
const f = withSessionPrefix(); mutate(f); expect(f.pending).toThrow("invalid/duplicate/foreign canonical row");
|
||||
});
|
||||
it("does not let a valid v2 prefix hide the retained incomplete-evidence failure", () => {
|
||||
const f = withSessionPrefix();
|
||||
const notice = f.events.find(row => row.eventType === "provider.notice.recorded")!;
|
||||
payload(notice).provenance.eventType = "evidence_incomplete";
|
||||
payload(notice).details = Object.entries({ stage: "evidence_incomplete", toolCallId: "unavailable", reason: "projection_failed" }).map(([name, value]) => ({ name, value }));
|
||||
expect(f.pending).toThrow("Copilot evidence is explicitly incomplete");
|
||||
});
|
||||
it("binds an optional card tool ID through the exact native permission after a valid v2 prefix", () => {
|
||||
const f = withSessionPrefix();
|
||||
delete payload(f.events.find(row => row.eventType === "runtime_request.created")!).request.details.toolCallId;
|
||||
expect(f.pending()).toMatchObject({ toolCallId: "tool", requestId: "request" });
|
||||
payload(f.events.find(row => row.eventType === "runtime_request.created")!).request.requestId = "foreign";
|
||||
expect(f.pending).toThrow("native notice/card identity mismatch");
|
||||
});
|
||||
it.each(["copilot"] as const)("binds %s's unanswered callback to cancelled provider settlement and caller-owned Stop", provider => {
|
||||
const f = fixture(provider), pending = f.pending(); f.settle();
|
||||
expect(f.permissionResponse.mock.calls).toEqual([[{ action: "cancel" }]]);
|
||||
expect(readActiveStopSettlement({ ...f.state(), pending, dispatchMonotonicNs: (BigInt(pending.observedMonotonicNs) + 1n).toString() })).toMatchObject({
|
||||
schema: "paperclip.e2e.native-active-stop-settlement.v3", branch: "pending_permission_cancelled", normalCompletionAccepted: false, taskStillInProgress: true, replayAllowed: false,
|
||||
pending: { normalizedSessionId: "normalized-session", nativeSessionId: "native-session", cancellationRequestId },
|
||||
});
|
||||
});
|
||||
it("matches the retained Product closure shape with distinct native and item identities", () => {
|
||||
const { f, pending, read } = settled();
|
||||
expect(pending.toolCallId).toBe("tool");
|
||||
// Content-free shape captured from the failed Product attempt. These exact
|
||||
// payloads come from mapTerminalTurn -> cancelPendingRequests above.
|
||||
expect(payload(f.events[4]!)).toEqual({ requestId: "request", requestKind: "permission_approval",
|
||||
turnId: "turn", itemId: "item-run", reason: "turn_terminal" });
|
||||
expect(payload(f.events[5]!)).toEqual({ status: "cancelled", error: null });
|
||||
expect(read().schema).toBe("paperclip.e2e.native-active-stop-settlement.v3");
|
||||
});
|
||||
it("admits only a fully attested remote bootstrap read completed before the pending operation", () => {
|
||||
const f = fixture(), actionFile = `.paperclip-eval-action-${"a".repeat(36)}.txt`;
|
||||
const readTargetSha256 = `sha256:${createHash("sha256").update(actionFile).digest("hex")}`;
|
||||
for (const row of f.events) { row.seq += 4; frame(row).sourceSeq += 4; frame(row).sourceEventId = `source:run:${row.seq}`; }
|
||||
f.events.unshift(f.notice(1, "tool", { toolCallId: "bootstrap", operation: "read", status: "pending", readTargetSha256 }),
|
||||
f.row(2, "tool.execution.started", { schema: "paperclip.tool.execution.v1", executionId: "bootstrap", transport: "builtin", status: "running", operation: "read", target: actionFile }),
|
||||
f.notice(3, "tool", { toolCallId: "bootstrap", operation: "read", status: "completed", readTargetSha256 }),
|
||||
f.row(4, "tool.execution.completed", { schema: "paperclip.tool.execution.v1", executionId: "bootstrap", transport: "builtin", status: "completed", operation: "read", target: actionFile }));
|
||||
const bootstrap = { actionFile, events: f.events };
|
||||
expect(() => observeActiveStopPending({ ...f.state(), scope: f.scope, caller, cancellationRequestId, bootstrap })).not.toThrow();
|
||||
payload(f.events[2]!).details.find((d: Row) => d.name === "readTargetSha256").value = `sha256:${"0".repeat(64)}`;
|
||||
expect(() => observeActiveStopPending({ ...f.state(), scope: f.scope, caller, cancellationRequestId, bootstrap })).toThrow(/bootstrap/);
|
||||
});
|
||||
it("does not compare remote wall clock or transaction timestamps to the operator clock", () => {
|
||||
const f = fixture(); for (const row of f.events) { frame(row).emittedAt = "2099-01-01T00:00:00Z"; row.createdAt = "1900-01-01T00:00:00Z"; }
|
||||
const pending = f.pending(); f.settle();
|
||||
expect(() => readActiveStopSettlement({ ...f.state(), pending, dispatchMonotonicNs: (BigInt(pending.observedMonotonicNs) + 1n).toString() })).not.toThrow();
|
||||
});
|
||||
it.each([
|
||||
["prior Stop claim", (f: ReturnType<typeof fixture>) => { f.run.resultJson.startupCancellation = { cancellationRequestId }; }],
|
||||
["answered callback", (f: ReturnType<typeof fixture>) => { f.events.push(f.notice(5, "permission_delivered", { requestId: "request", outcome: "cancel" })); }],
|
||||
["already settled provider", (f: ReturnType<typeof fixture>) => { f.events.push(f.row(5, "turn.completed", { status: "completed" })); }],
|
||||
["already closed request", (f: ReturnType<typeof fixture>) => { f.events.push(f.row(5, "runtime_request.resolved", {})); }],
|
||||
["failed operation", (f: ReturnType<typeof fixture>) => { f.events.push(f.row(5, "tool.execution.completed", { schema: "paperclip.tool.execution.v1", transport: "builtin", executionId: "tool", status: "failed" })); }],
|
||||
] as const)("refuses %s before dispatch", (_label, mutate) => { const f = fixture(); mutate(f); expect(f.pending).toThrow(); });
|
||||
it.each([
|
||||
["completed terminal", ({ f }: ReturnType<typeof settled>) => { f.events[5]!.eventType = frame(f.events[5]!).eventType = "turn.completed"; payload(f.events[5]!).status = "completed"; }],
|
||||
["interrupted terminal", ({ f }: ReturnType<typeof settled>) => { f.events[5]!.eventType = frame(f.events[5]!).eventType = "turn.interrupted"; payload(f.events[5]!).status = "interrupted"; }],
|
||||
["failed terminal", ({ f }: ReturnType<typeof settled>) => { payload(f.events[5]!).error = { code: "failure" }; }],
|
||||
["duplicate terminal", ({ f }: ReturnType<typeof settled>) => { f.events.push(f.row(7, "turn.cancelled", payload(f.events[5]!))); }],
|
||||
["late same-tool replay", ({ f }: ReturnType<typeof settled>) => { f.events.push(f.notice(7, "tool", { status: "in_progress" })); }],
|
||||
["wrong closed item", ({ f }: ReturnType<typeof settled>) => { payload(f.events[4]!).itemId = "other"; }],
|
||||
["reordered source stream", ({ f }: ReturnType<typeof settled>) => { frame(f.events[5]!).sourceSeq = 1; frame(f.events[5]!).sourceEventId = "source:run:1"; }],
|
||||
["missing terminal", ({ f }: ReturnType<typeof settled>) => { f.events.pop(); }],
|
||||
["wrapper type mismatch", ({ f }: ReturnType<typeof settled>) => { f.events[5]!.eventType = "turn.completed"; }],
|
||||
["foreign normalized session", ({ f }: ReturnType<typeof settled>) => { frame(f.events[5]!).normalizedSessionId = "native-session"; }],
|
||||
["foreign source", ({ f }: ReturnType<typeof settled>) => { frame(f.events[5]!).sourceInstanceId = "foreign"; }],
|
||||
["foreign turn", ({ f }: ReturnType<typeof settled>) => { frame(f.events[5]!).turnId = "other-turn"; }],
|
||||
["foreign company", ({ f }: ReturnType<typeof settled>) => { f.events[5]!.companyId = "other"; }],
|
||||
["foreign request", ({ f }: ReturnType<typeof settled>) => { payload(f.events[4]!).requestId = "other"; }],
|
||||
["expired rather than cancelled", ({ f }: ReturnType<typeof settled>) => { f.events[4]!.eventType = frame(f.events[4]!).eventType = "runtime_request.expired"; }],
|
||||
["provider death", ({ f }: ReturnType<typeof settled>) => { payload(f.events[4]!).reason = "provider_process_lost"; }],
|
||||
["raw backend closure is not the Product contract", ({ f }: ReturnType<typeof settled>) => { payload(f.events[4]!).reason = "explicit_cancellation"; }],
|
||||
["answered closure", ({ f }: ReturnType<typeof settled>) => { payload(f.events[4]!).action = "accept"; }],
|
||||
["resolved request", ({ f }: ReturnType<typeof settled>) => { f.events[4]!.eventType = frame(f.events[4]!).eventType = "runtime_request.resolved"; }],
|
||||
["duplicate closure", ({ f }: ReturnType<typeof settled>) => { f.events.push(f.row(7, "runtime_request.cancelled", payload(f.events[4]!))); }],
|
||||
["foreign closure turn", ({ f }: ReturnType<typeof settled>) => { payload(f.events[4]!).turnId = "other"; }],
|
||||
["missing acknowledgement", ({ f }: ReturnType<typeof settled>) => { delete f.run.resultJson.nativeCancellation.acknowledgementAuditId; }],
|
||||
["unacknowledged intent", ({ f }: ReturnType<typeof settled>) => { f.run.resultJson.nativeCancellation.dispatchState = "pending"; }],
|
||||
["replay permitted", ({ f }: ReturnType<typeof settled>) => { payload(f.events[4]!).replayAllowed = true; }],
|
||||
["tampered pending row", ({ f }: ReturnType<typeof settled>) => { payload(f.events[3]!).request.itemId = "changed"; }],
|
||||
["missing pending receipt", (s: ReturnType<typeof settled>) => { s.pending.requestRowSha256 = ""; }],
|
||||
["legacy v1 pending receipt", (s: ReturnType<typeof settled>) => { (s.pending as Row).schema = "paperclip.e2e.native-active-stop-pending.v1"; }],
|
||||
["missing native origin binding", (s: ReturnType<typeof settled>) => { delete (s.pending as Row).toolOriginRowSha256; }],
|
||||
["missing tool start binding", (s: ReturnType<typeof settled>) => { delete (s.pending as Row).toolStartedRowSha256; }],
|
||||
["changed native origin", ({ f }: ReturnType<typeof settled>) => { frame(f.events[0]!).emittedAt = "2026-01-01T00:00:01Z"; }],
|
||||
["changed tool start", ({ f }: ReturnType<typeof settled>) => { frame(f.events[1]!).emittedAt = "2026-01-01T00:00:01Z"; }],
|
||||
["wrong origin sequence", (s: ReturnType<typeof settled>) => { s.pending.toolOriginSourceSeq += 1; }],
|
||||
["wrong start sequence", (s: ReturnType<typeof settled>) => { s.pending.toolStartedSourceSeq += 1; }],
|
||||
["missing caller", ({ f }: ReturnType<typeof settled>) => { delete f.run.resultJson.cancelledByUserId; }],
|
||||
["null caller", ({ f }: ReturnType<typeof settled>) => { f.run.resultJson.cancelledByUserId = null; }],
|
||||
["foreign caller", ({ f }: ReturnType<typeof settled>) => { f.run.resultJson.cancellation.initiator.id = "another-board-user"; }],
|
||||
["agent caller", ({ f }: ReturnType<typeof settled>) => { f.run.resultJson.cancelledByActorType = "agent"; }],
|
||||
["unbound caller receipt", (s: ReturnType<typeof settled>) => { (s.pending as Row).caller = null; }],
|
||||
["foreign intent", ({ f }: ReturnType<typeof settled>) => { f.run.resultJson.nativeCancellation.intentId = "native-cancellation:other"; }],
|
||||
["foreign ack scope", ({ f }: ReturnType<typeof settled>) => { f.run.resultJson.nativeCancellation.companyId = "other"; }],
|
||||
["undispatched cancellation", ({ f }: ReturnType<typeof settled>) => { f.run.resultJson.nativeCancellation.dispatched = false; }],
|
||||
["same audit twice", ({ f }: ReturnType<typeof settled>) => { f.run.resultJson.nativeCancellation.acknowledgementAuditId = "audit-intent"; }],
|
||||
["finished task", ({ f }: ReturnType<typeof settled>) => { f.issue.status = "done"; }],
|
||||
["extra semantic operation", ({ f }: ReturnType<typeof settled>) => { f.events.push(f.row(7, "tool.execution.started", { executionId: "other" })); }],
|
||||
["extra native operation", ({ f }: ReturnType<typeof settled>) => { f.events.push(f.notice(7, "tool", { toolCallId: "other", status: "pending" })); }],
|
||||
["native session switched", ({ f }: ReturnType<typeof settled>) => { payload(f.events[0]!).provenance.sessionId = "other"; }],
|
||||
["target operation completed", ({ f }: ReturnType<typeof settled>) => { f.events.push(f.row(7, "tool.execution.completed", { schema: "paperclip.tool.execution.v1", transport: "builtin", executionId: "tool", status: "completed" })); }],
|
||||
] as const)("rejects %s", (_label, mutate) => { const s = settled(); mutate(s); expect(s.read).toThrow(); });
|
||||
it.each(["closure", "terminal"] as const)("rejects missing/null envelope turn and foreign null-turn stream on %s", kind => {
|
||||
for (const turn of [null, undefined]) for (const mismatch of ["none", "session", "source"]) {
|
||||
const s = settled(), event = frame(s.f.events[kind === "closure" ? 4 : 5]!);
|
||||
event.turnId = turn;
|
||||
if (mismatch === "session") event.normalizedSessionId = "foreign-session";
|
||||
if (mismatch === "source") { event.sourceInstanceId = "foreign-source"; event.sourceEventId = `foreign-source:run:${event.sourceSeq}`; }
|
||||
expect(s.read).toThrow(/pending callback/);
|
||||
}
|
||||
});
|
||||
it.each([
|
||||
[{ deploymentMode: "authenticated" }, { session: { userId: "local-board", id: "paperclip:local_implicit:local-board" } }],
|
||||
[{ deploymentMode: "local_trusted" }, { session: { userId: null, id: "paperclip:local_implicit:local-board" } }],
|
||||
[{ deploymentMode: "local_trusted" }, { session: { userId: "another", id: "paperclip:local_implicit:another" } }],
|
||||
[{ deploymentMode: "local_trusted" }, { session: { userId: "local-board", id: "paperclip:session:local-board" } }],
|
||||
])("rejects a non-fixture authentication context", (health, session) => expect(() => readActiveStopCaller(health, session)).toThrow());
|
||||
it("rejects a Stop dispatch not causally after the pending observation", () => {
|
||||
const { f, pending } = settled();
|
||||
expect(() => readActiveStopSettlement({ ...f.state(), pending, dispatchMonotonicNs: pending.observedMonotonicNs })).toThrow(/pre-dispatch/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("active Stop flow wiring", () => {
|
||||
it.each([
|
||||
|
||||
["copilot", "tool-first"], ["copilot", "permission-first"],
|
||||
] as const)("awaits retention and rechecks real %s %s evidence before issuing Stop with a retained fixture correlation", async (provider, arrivalOrder) => {
|
||||
const f = withProjectedArrivalOrder(provider, arrivalOrder); const order: string[] = []; let retainedId = "";
|
||||
const stop = vi.fn(async (runId: string, id: string) => { expect(runId).toBe("run"); expect(id).toBe(retainedId); order.push("stop"); return f.settle(id); });
|
||||
const result = await stopAtPendingPermission({ scope: f.scope, caller, deadlineAt: Date.now() + 1000,
|
||||
load: async () => { order.push("load"); return f.state(); },
|
||||
retain: async receipt => { order.push("retain-start"); await Promise.resolve(); retainedId = receipt.cancellationRequestId; order.push("retain-done"); }, stop });
|
||||
expect(order).toEqual(["load", "retain-start", "retain-done", "load", "stop", "load"]);
|
||||
expect(stop).toHaveBeenCalledTimes(1); expect(result.settlement.branch).toBe("pending_permission_cancelled");
|
||||
});
|
||||
it.each(["retention failed", "competing Stop", "answered after capture"])("never sends Stop when %s", async mode => {
|
||||
const f = fixture(), stop = vi.fn();
|
||||
await expect(stopAtPendingPermission({ scope: f.scope, caller, deadlineAt: Date.now() + 1000, load: async () => f.state(), stop,
|
||||
retain: async () => { if (mode === "retention failed") throw Error("disk failure"); if (mode === "competing Stop") f.run.resultJson.startupCancellation = { cancellationRequestId: "foreign" }; else f.events.push(f.row(5, "runtime_request.resolved", {})); },
|
||||
})).rejects.toThrow(); expect(stop).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["native origin", "canonical start"])("never sends Stop when the retained %s changes before the fresh reread", async kind => {
|
||||
const f = withArrivalOrder("copilot", "permission-first"), stop = vi.fn();
|
||||
await expect(stopAtPendingPermission({ scope: f.scope, caller, deadlineAt: Date.now() + 1000, load: async () => f.state(), stop,
|
||||
retain: async () => { frame(f.events[kind === "native origin" ? 1 : 2]!).emittedAt = "2026-01-01T00:00:01Z"; },
|
||||
})).rejects.toThrow("pending boundary changed or expired");
|
||||
expect(stop).not.toHaveBeenCalled();
|
||||
});
|
||||
it("never sends Stop to make a missing tool start arrive", async () => {
|
||||
const f = withArrivalOrder("copilot", "permission-first");
|
||||
const start = f.events.splice(2, 1)[0]!, retain = vi.fn();
|
||||
const stop = vi.fn(async () => { f.events.push(start); return f.settle(); });
|
||||
await expect(stopAtPendingPermission({ scope: f.scope, caller, deadlineAt: Date.now() + 1000, load: async () => f.state(), retain, stop }))
|
||||
.rejects.toThrow("duplicate canonical operation lifecycle");
|
||||
expect(retain).not.toHaveBeenCalled(); expect(stop).not.toHaveBeenCalled();
|
||||
});
|
||||
it("fails promptly on a definitive provider failure before calling a throwing evidence reader", async () => {
|
||||
const f = fixture(); const started = Date.now();
|
||||
await expect(stopAtPendingPermission({ scope: f.scope, caller, deadlineAt: started + 10000, load: async () => f.state(), retain: async () => {}, stop: async (_run, id) => {
|
||||
const response = structuredClone(f.settle(id)); f.run.status = "failed"; f.events.length = 0; return response;
|
||||
} })).rejects.toThrow(/Stopped waiting.*unexpected run terminal/);
|
||||
expect(Date.now() - started).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("explicit active Stop discovery", () => {
|
||||
it("adds exactly two versioned cells without enabling them in --all", () => {
|
||||
const suite = runnerSuites.find(s => s.id === "native-active-stop")!;
|
||||
const allCells = runnerMatrix.filter(e => e.suite === suite);
|
||||
expect(allCells).toHaveLength(4);
|
||||
const cells = allCells.filter(e => e.profile.qualificationCandidate === "copilot");
|
||||
expect(cells).toHaveLength(2); expect(suite.manualOnly).toBe(true);
|
||||
expect(cells.map(e => `${e.profile.qualificationCandidate}/${e.environment.id}`).sort()).toEqual(["copilot/daytona", "copilot/local"]);
|
||||
expect(cells.every(e => e.task.expectedRunCount === 1 && e.task.flow === "native_active_stop" && e.task.expectedTerminalState?.run === "cancelled")).toBe(true);
|
||||
expect(suite.definitionMetadata?.copilotDefinition).toMatchObject({ version: 18, titleCreation: "explicit-search-composer", taskBinding: "browser-creation-response-id", bootstrapPrompt: "already-exposed-dedicated-context-before-native-edit", evidence: "paperclip.e2e.native-active-stop-settlement.v3", normalCompletionAccepted: false, providerDeath: "not-covered" });
|
||||
expect(suiteDefinitionHash(suite)).not.toBe(suiteDefinitionHash({ ...suite, definitionMetadata: { ...suite.definitionMetadata, version: 3 } }));
|
||||
expect(suiteDefinitionHash(suite)).not.toBe(suiteDefinitionHash({ ...suite, definitionMetadata: { ...suite.definitionMetadata, normalCompletionAccepted: true } }));
|
||||
expect(selectRunnerExecutions(parseRunnerSelectors(["--all"])).some(e => e.suite.id === suite.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("active Stop cleanup remains mandatory", () => {
|
||||
const proof = () => ({ environment: "local" as const, completed: true, identityChanged: false, processes: { captured: true, live: [] as number[] },
|
||||
watcher: { complete: true, targetMutationCount: 0 }, samples: ["before-request", "pending", "after-stop", "after-cleanup"].map(phase => ({ phase, absent: true })) });
|
||||
it("requires all causal samples plus the continuous watcher and retired owned identities", () => expect(() => assertActiveStopRetirement(proof())).not.toThrow());
|
||||
it.each([
|
||||
["live child", (p: ReturnType<typeof proof>) => { p.processes.live.push(42); }],
|
||||
["unobserved process", (p: ReturnType<typeof proof>) => { p.processes.captured = false; }],
|
||||
["replaced root", (p: ReturnType<typeof proof>) => { p.identityChanged = true; }],
|
||||
["incomplete watch", (p: ReturnType<typeof proof>) => { p.watcher.complete = false; }],
|
||||
["transient write", (p: ReturnType<typeof proof>) => { p.watcher.targetMutationCount = 1; }],
|
||||
["written target", (p: ReturnType<typeof proof>) => { p.samples[2]!.absent = false; }],
|
||||
["missing final sample", (p: ReturnType<typeof proof>) => { p.samples.pop(); }],
|
||||
["missing settlement", (p: ReturnType<typeof proof>) => { p.completed = false; }],
|
||||
] as const)("rejects %s even after an apparent cancelled run", (_label, mutate) => { const p = proof(); mutate(p); expect(() => assertActiveStopRetirement(p)).toThrow(); });
|
||||
});
|
||||
|
||||
function remoteRetirementProof() {
|
||||
const root = { pid: 50, ppid: 1, startTicks: "200", bootId: "12345678-1234-1234-1234-123456789abc" };
|
||||
const child = { ...root, pid: 51, ppid: 50, startTicks: "201" };
|
||||
const snapshot = (sequence: number, live: number[]) => ({
|
||||
binding: { companyId: "company", environmentId: "env", runId: "run", leaseId: "lease", sandboxId: "sandbox", image: `image@sha256:${"a".repeat(64)}`, remoteCwd: "/home/daytona/workspace" },
|
||||
observedAtMs: sequence, receivedAtMs: sequence + 1, observedMonotonicNs: String(sequence), complete: true, workspace: {},
|
||||
targets: { "target.txt": { absent: true, sha256: null, complete: true, mutationCount: 0, parent: { dev: "1", ino: "2" } } },
|
||||
watcher: { complete: true, targetMutationCount: 0, workspaceMutationCount: 0 },
|
||||
processes: { captured: true, root, journal: [root, child], live },
|
||||
setup: { path: ".action.txt", sha256: sequence === 1 ? null : `sha256:${"b".repeat(64)}`, published: sequence !== 1 }, attached: null,
|
||||
});
|
||||
const observations: ActiveStopRemoteObservation[] = [
|
||||
{ phase: "before-request", source: "live-snapshot", snapshot: snapshot(1, [50, 51]) },
|
||||
{ phase: "pending", source: "live-snapshot", snapshot: snapshot(2, [50, 51]) },
|
||||
{ phase: "owned-process-retirement", source: "retirement-seal", snapshot: snapshot(3, []) },
|
||||
];
|
||||
return { scope: { companyId: "company", runId: "run", target: "target.txt" }, observations };
|
||||
}
|
||||
describe("Daytona active Stop observation lifetime", () => {
|
||||
it("binds one continuous owned-tree retirement seal without claiming a later filesystem observation", () => {
|
||||
const remote = remoteRetirementProof(), terminal = remote.observations[2]!.snapshot;
|
||||
expect(assertActiveStopRetirement({ environment: "daytona", completed: true, identityChanged: false,
|
||||
processes: terminal.processes, watcher: terminal.watcher, remote })).toMatchObject({
|
||||
schema: "paperclip.e2e.native-active-stop-remote-retirement.v1", coverage: "continuous-through-owned-process-retirement",
|
||||
filesystemAfterRetirementObserved: false, observations: [
|
||||
{ phase: "before-request", source: "live-snapshot" }, { phase: "pending", source: "live-snapshot" },
|
||||
{ phase: "owned-process-retirement", source: "retirement-seal" },
|
||||
],
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
["seal relabeled fresh", (p: ReturnType<typeof remoteRetirementProof>) => { p.observations[2]!.source = "live-snapshot"; }],
|
||||
["seal replayed as pending", (p: ReturnType<typeof remoteRetirementProof>) => { p.observations[1]!.snapshot = p.observations[2]!.snapshot; }],
|
||||
["after-UI seal replay", (p: ReturnType<typeof remoteRetirementProof>) => { p.observations.push({ ...p.observations[2]!, phase: "after-cleanup" as any }); }],
|
||||
["missing pending", (p: ReturnType<typeof remoteRetirementProof>) => { p.observations.splice(1, 1); }],
|
||||
["all foreign run snapshots", (p: ReturnType<typeof remoteRetirementProof>) => { for (const o of p.observations) o.snapshot.binding.runId = "other"; }],
|
||||
["all foreign company snapshots", (p: ReturnType<typeof remoteRetirementProof>) => { for (const o of p.observations) o.snapshot.binding.companyId = "other"; }],
|
||||
["foreign lease", (p: ReturnType<typeof remoteRetirementProof>) => { p.observations[2]!.snapshot.binding.leaseId = "other"; }],
|
||||
["foreign run", (p: ReturnType<typeof remoteRetirementProof>) => { p.observations[1]!.snapshot.binding.runId = "other"; }],
|
||||
["replacement root", (p: ReturnType<typeof remoteRetirementProof>) => { p.observations[2]!.snapshot.processes.root = { ...p.observations[2]!.snapshot.processes.root!, startTicks: "300" }; }],
|
||||
["live child", (p: ReturnType<typeof remoteRetirementProof>) => { p.observations[2]!.snapshot.processes.live = [51]; }],
|
||||
["lost child journal", (p: ReturnType<typeof remoteRetirementProof>) => { p.observations[2]!.snapshot.processes.journal.pop(); }],
|
||||
["unobserved pending root", (p: ReturnType<typeof remoteRetirementProof>) => { p.observations[1]!.snapshot.processes.live = []; }],
|
||||
["transient target mutation", (p: ReturnType<typeof remoteRetirementProof>) => { p.observations[2]!.snapshot.watcher.targetMutationCount = 1; }],
|
||||
["workspace mutation", (p: ReturnType<typeof remoteRetirementProof>) => { p.observations[2]!.snapshot.watcher.workspaceMutationCount = 1; }],
|
||||
["late target present", (p: ReturnType<typeof remoteRetirementProof>) => { p.observations[2]!.snapshot.targets["target.txt"]!.absent = false; }],
|
||||
["incomplete watcher", (p: ReturnType<typeof remoteRetirementProof>) => { p.observations[2]!.snapshot.watcher.complete = false; }],
|
||||
["changed action", (p: ReturnType<typeof remoteRetirementProof>) => { p.observations[2]!.snapshot.setup.sha256 = `sha256:${"c".repeat(64)}`; }],
|
||||
] as const)("rejects %s", (_label, mutate) => {
|
||||
const proof = remoteRetirementProof(); mutate(proof); expect(() => readActiveStopRemoteRetirement(proof)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("active Stop process authority by environment", () => {
|
||||
const run = { id: "owned-run", processPid: 101, processGroupId: 101, processStartedAt: "2026-10-05T00:00:00Z" };
|
||||
it("ignores controller transport PID rotation for a remote run", () => {
|
||||
expect(localActiveStopProcessAuthority(run, true)).toBeUndefined();
|
||||
expect(localActiveStopProcessAuthority({ ...run, processPid: 202, processGroupId: 202 }, true)).toBeUndefined();
|
||||
});
|
||||
it("retains local PID/start/run authority so a replacement still changes identity", () => {
|
||||
expect(localActiveStopProcessAuthority(run, false)).toEqual({ pid: 101, groupId: 101, startedAt: run.processStartedAt, runId: "owned-run" });
|
||||
expect(localActiveStopProcessAuthority({ ...run, processPid: 202 }, false)).not.toEqual(localActiveStopProcessAuthority(run, false));
|
||||
expect(localActiveStopProcessAuthority({ ...run, processStartedAt: "2026-10-05T00:01:00Z" }, false)).not.toEqual(localActiveStopProcessAuthority(run, false));
|
||||
expect(localActiveStopProcessAuthority({ id: run.id }, false)).toBeUndefined();
|
||||
});
|
||||
it("retains the admitted Linux birth identity when only the API timestamp refreshes", async () => {
|
||||
const { activeStopOwnedProcessKey } = await import("./copilot-active-stop-flow.js");
|
||||
const authority = localActiveStopProcessAuthority(run, false)!;
|
||||
const processes = { captured: true, journal: [{ pid: 101, bootId: "00000000-0000-0000-0000-000000000001", startTicks: "1000" }] };
|
||||
const key = activeStopOwnedProcessKey(authority, processes);
|
||||
expect(activeStopOwnedProcessKey({ ...authority, startedAt: "2026-10-05T00:00:04Z" }, processes)).toBe(key);
|
||||
for (const changed of [{ pid: 102 }, { groupId: 102 }, { runId: "different-run" }]) {
|
||||
expect(activeStopOwnedProcessKey({ ...authority, ...changed }, processes)).not.toBe(key);
|
||||
}
|
||||
expect(activeStopOwnedProcessKey(authority, { ...processes, journal: [{ ...processes.journal[0]!, startTicks: "1001" }] })).not.toBe(key);
|
||||
expect(activeStopOwnedProcessKey(authority, { ...processes, journal: [{ ...processes.journal[0]!, bootId: "00000000-0000-0000-0000-000000000002" }] })).not.toBe(key);
|
||||
expect(activeStopOwnedProcessKey(authority, { captured: false })).toBeUndefined();
|
||||
expect(activeStopOwnedProcessKey(undefined, processes)).toBeUndefined();
|
||||
expect(activeStopOwnedProcessKey(authority, { captured: true })).not.toBe(activeStopOwnedProcessKey({ ...authority, startedAt: "2026-10-05T00:00:04Z" }, { captured: true }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { applyMainlineStopMetadata, stopAcknowledgementForTest } from "./copilot-stop-test-fixtures.js";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readCopilotContextRead } from "./copilot-context-evidence.js";
|
||||
import { readCopilotToolEvidence } from "./copilot-evidence.js";
|
||||
import { observeActiveStopPending, readActiveStopCaller, readActiveStopSettlement } from "./copilot-active-stop-evidence.js";
|
||||
import { assertCopilotProviderDeath } from "./copilot-provider-death.js";
|
||||
|
||||
type Row = Record<string, any>;
|
||||
const retained = JSON.parse(await readFile(new URL("./fixtures/copilot-context-attached-completion-v15.json", import.meta.url), "utf8"));
|
||||
const discoveryFixture = JSON.parse(await readFile(new URL("./fixtures/copilot-context-discovery-permission-v15.json", import.meta.url), "utf8"));
|
||||
const pendingFixture = JSON.parse(await readFile(new URL("./fixtures/copilot-context-permission-v15.json", import.meta.url), "utf8"));
|
||||
const frame = (r: Row) => r.payload.prpEvent;
|
||||
const payload = (r: Row) => frame(r).payload;
|
||||
const setDetail = (r: Row, name: string, value: string) => { payload(r).details.find((d: Row) => d.name === name).value = value; };
|
||||
const scope = { provider: "copilot" as const, companyId: "company", issueId: "issue", runId: "run", target: "target.txt", requireContextRead: true };
|
||||
const caller = readActiveStopCaller({ deploymentMode: "local_trusted" }, { session: { userId: "local-board", id: "paperclip:local_implicit:local-board" } });
|
||||
const cancellationRequestId = "11111111-2222-4333-8444-555555555555";
|
||||
function row(seq: number, eventType: string, p: Row): Row {
|
||||
return { companyId: "company", runId: "run", seq, eventType, protocolSchemaVersion: 1, sourceInstanceId: "source", sourceSeq: seq,
|
||||
payload: { prpEvent: { schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", eventType, runId: "run", turnId: "turn",
|
||||
normalizedSessionId: "session", sourceInstanceId: "source", sourceSeq: seq, sourceEventId: `source:run:${seq}`, emittedAt: "2026-10-03T00:00:00Z", payload: p } } };
|
||||
}
|
||||
function notice(seq: number, toolCallId: string, stage: string, fields: Row) {
|
||||
return row(seq, "provider.notice.recorded", { schema: "paperclip.provider.notice.v1", scope: "turn", category: "copilot_tool_evidence_v1",
|
||||
provenance: { sessionId: "native-session", turnId: "turn", eventType: stage, method: stage === "tool" ? "session/update" : "session/request_permission" },
|
||||
details: Object.entries({ stage, toolCallId, ...fields }).map(([name, value]) => ({ name, value: String(value) })) });
|
||||
}
|
||||
function fixture() {
|
||||
// Preserve the retained successful semantic receipt and tool hashes. Add a
|
||||
// normal permission-card decision using the independently retained pending
|
||||
// card; these synthetic combinations are calibration, never live evidence.
|
||||
const context = structuredClone(retained.rows.slice(0, 5));
|
||||
const contextId = payload(context[0]).details.find((d: Row) => d.name === "toolCallId").value;
|
||||
for (const r of context) {
|
||||
if (payload(r).provenance) Object.assign(payload(r).provenance, { sessionId: "native-session", turnId: "turn" });
|
||||
}
|
||||
const request = structuredClone(payload(pendingFixture.rows[0]).request);
|
||||
Object.assign(request, { requestId: "context-request", turnId: "turn", itemId: "context-item" });
|
||||
const events = [row(1, context[0].eventType, payload(context[0])), row(2, context[1].eventType, payload(context[1])),
|
||||
row(3, "runtime_request.created", { request }), notice(4, contextId, "permission_requested", { requestId: "context-request", declineOffered: true }),
|
||||
row(5, "runtime_request.resolved", { requestId: "context-request", turnId: "turn", requestKind: "permission_approval", action: "accept" }),
|
||||
notice(6, contextId, "permission_delivered", { requestId: "context-request", outcome: "allow_once" }),
|
||||
row(7, context[2].eventType, payload(context[2])), row(8, context[3].eventType, payload(context[3])), row(9, context[4].eventType, payload(context[4])),
|
||||
notice(10, "edit-tool", "tool", { status: "pending", operation: "edit", target: "target.txt" }),
|
||||
row(11, "tool.execution.started", { schema: "paperclip.tool.execution.v1", executionId: "edit-tool", transport: "builtin", status: "running", operation: "edit", target: "target.txt" }),
|
||||
row(12, "runtime_request.created", { request: { schema: "paperclip.runtime_request.v2", requestKind: "permission_approval", type: "permission", status: "pending",
|
||||
requestId: "edit-request", turnId: "turn", itemId: "edit-item", details: { toolCallId: "edit-tool" }, origin: { adapter: "acpx-runtime-sidecar", provider: "copilot", method: "session/request_permission" } } }),
|
||||
notice(13, "edit-tool", "permission_requested", { requestId: "edit-request", declineOffered: true, operation: "edit", target: "target.txt" })];
|
||||
const run: Row = { id: "run", companyId: "company", nativeIssueId: "issue", runtimeMode: "native", status: "running", resultJson: {} };
|
||||
const issue: Row = { id: "issue", companyId: "company", status: "in_progress" };
|
||||
const origin = () => readCopilotToolEvidence(events, "run").find(n => n.toolCallId === "edit-tool" && n.stage === "tool")!;
|
||||
const pending = () => observeActiveStopPending({ events, run, issue, scope, caller, cancellationRequestId });
|
||||
return { events, run, issue, origin, pending };
|
||||
}
|
||||
describe("successful context read with an explicit permission decision", () => {
|
||||
it("binds the current Copilot permission origin to the same successful read receipt", () => {
|
||||
const f = fixture();
|
||||
payload(f.events[2]).request.origin.provider = "copilot";
|
||||
// Current sidecar order: native request notice, then durable permission card.
|
||||
for (const [index, seq] of [[2, 4], [3, 3]]) {
|
||||
f.events[index].seq = seq; f.events[index].sourceSeq = seq;
|
||||
frame(f.events[index]).sourceSeq = seq; frame(f.events[index]).sourceEventId = `source:run:${seq}`;
|
||||
}
|
||||
expect(readCopilotContextRead(f.events, f.origin(), true)).toMatchObject({ permissionRequestId: "context-request", receiptSeq: 7 });
|
||||
expect(f.pending()).toMatchObject({ requestId: "edit-request" });
|
||||
for (const provider of ["cursor", "pi", "claude", "codex", "unknown"]) {
|
||||
payload(f.events[2]).request.origin.provider = provider;
|
||||
expect(() => readCopilotContextRead(f.events, f.origin(), true)).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("attests the closed context read while retaining the unanswered mutation", () => {
|
||||
const f = fixture();
|
||||
expect(readCopilotContextRead(f.events, f.origin(), true)).toMatchObject({ permissionRequestId: "context-request", pendingSeq: 1, receiptSeq: 7, completedSeq: 8 });
|
||||
expect(f.pending()).toMatchObject({ requestId: "edit-request", toolCallId: "edit-tool" });
|
||||
});
|
||||
it.each([
|
||||
["another operation", (rows: Row[]) => { payload(rows[2]).request.prompt = "report_progress"; }],
|
||||
["foreign company", (rows: Row[]) => { rows[4].companyId = "other"; }],
|
||||
["foreign turn", (rows: Row[]) => { frame(rows[4]).turnId = "other"; }],
|
||||
["foreign source", (rows: Row[]) => { frame(rows[5]).sourceInstanceId = "other"; }],
|
||||
["foreign bridge", (rows: Row[]) => { payload(rows[2]).request.origin.adapter = "untrusted"; }],
|
||||
["session-wide approval", (rows: Row[]) => { setDetail(rows[5], "outcome", "allow_always"); }],
|
||||
["rejected read", (rows: Row[]) => { payload(rows[4]).action = "decline"; }],
|
||||
["late resolution", (rows: Row[]) => { rows[4].seq = 14; }],
|
||||
["card after resolution", (rows: Row[]) => { rows[2].seq = 14; }],
|
||||
["native request after resolution", (rows: Row[]) => { rows[3].seq = 14; }],
|
||||
["missing resolution", (rows: Row[]) => { rows.splice(4, 1); }],
|
||||
["replayed delivery", (rows: Row[]) => { rows.push(structuredClone(rows[5])); }],
|
||||
["mutation target", (rows: Row[]) => { payload(rows[3]).details.push({ name: "target", value: "target.txt" }); }],
|
||||
] as const)("refuses %s as a bootstrap exception", (_label, mutate) => {
|
||||
const f = fixture(); mutate(f.events);
|
||||
expect(() => readCopilotContextRead(f.events, f.origin(), true)).toThrow();
|
||||
expect(f.pending).toThrow();
|
||||
});
|
||||
it("settles only the pending write after Stop, without treating the earlier context answer as a write answer", () => {
|
||||
const f = fixture(), pending = f.pending();
|
||||
f.events.push(row(14, "runtime_request.cancelled", { requestId: "edit-request", turnId: "turn", requestKind: "permission_approval", itemId: "edit-item", reason: "turn_terminal" }),
|
||||
row(15, "turn.cancelled", { status: "cancelled", error: null }));
|
||||
f.run.status = "cancelled";
|
||||
f.run.resultJson = { startupCancellation: { cancellationRequestId, requestedBy: { type: "board", userId: "local-board" } }, nativeCancellation: {
|
||||
schema: "paperclip.native-cancellation.v1", intentId: `native-cancellation:${cancellationRequestId}`, companyId: "company", runId: "run", issueId: "issue", scope: "run",
|
||||
dispatched: true, dispatchState: "acknowledged", reasonCode: "cancellation_run_only", effects: ["release_run_resources"], intentAuditId: "intent-audit", acknowledgementAuditId: "ack-audit" } };
|
||||
applyMainlineStopMetadata(f.run);
|
||||
const stopAcknowledgement = stopAcknowledgementForTest(f.run, cancellationRequestId, (BigInt(pending.observedMonotonicNs) + 1n).toString());
|
||||
expect(readActiveStopSettlement({ ...f, stopAcknowledgement, pending, dispatchMonotonicNs: (BigInt(pending.observedMonotonicNs) + 1n).toString() })).toMatchObject({ normalCompletionAccepted: false, replayAllowed: false });
|
||||
f.events.push(row(16, "runtime_request.resolved", { requestId: "edit-request", action: "accept" }));
|
||||
expect(() => readActiveStopSettlement({ ...f, stopAcknowledgement, pending, dispatchMonotonicNs: (BigInt(pending.observedMonotonicNs) + 1n).toString() })).toThrow();
|
||||
});
|
||||
it("accepts provider death after the read and rejects a late mutation answer or any other approval", () => {
|
||||
const f = fixture(), pending = f.pending();
|
||||
f.run.status = "failed"; f.issue.status = "blocked";
|
||||
f.events.push(row(14, "runtime_request.expired", { requestId: "edit-request", turnId: "turn", requestKind: "permission_approval", reason: "provider_exit" }),
|
||||
row(15, "turn.failed", { error: { code: "AGENT_DISCONNECTED", message: "Provider exited" } }));
|
||||
expect(assertCopilotProviderDeath({ ...f, pending })).toMatchObject({ expired: true, mutationReplay: false });
|
||||
for (const extra of [notice(16, "edit-tool", "permission_delivered", { requestId: "edit-request", outcome: "allow_once" }),
|
||||
notice(16, "other-tool", "permission_delivered", { requestId: "other-request", outcome: "allow_once" }),
|
||||
notice(16, "edit-tool", "tool", { operation: "edit", target: "target.txt", status: "completed" })]) {
|
||||
expect(() => assertCopilotProviderDeath({ ...f, pending, events: [...f.events, extra] })).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
function withDiscovery() {
|
||||
const f = fixture();
|
||||
for (const r of f.events) { r.seq += 9; r.sourceSeq += 9; frame(r).sourceSeq += 9; frame(r).sourceEventId = `source:run:${frame(r).sourceSeq}`; }
|
||||
const request = structuredClone(payload(discoveryFixture.rows[0]).request);
|
||||
Object.assign(request, { requestId: "discovery-request", turnId: "turn", itemId: "discovery-item" });
|
||||
const inputHash = createHash("sha256").update('{"query":"get_task_context"}').digest("hex");
|
||||
const receipt = structuredClone(payload(retained.rows[2]));
|
||||
Object.assign(receipt.provenance, { sessionId: "native-session", turnId: "turn" });
|
||||
const values = { operationId: "search_api", callIdentitySha256: "a".repeat(64), inputSha256: inputHash, resultSha256: "b".repeat(64) };
|
||||
for (const [name, value] of Object.entries(values)) receipt.details.find((d: Row) => d.name === name).value = value;
|
||||
const start = structuredClone(payload(discoveryFixture.rows[2]));
|
||||
Object.assign(start, { executionId: "discovery-tool" });
|
||||
const discovery = [row(1, "runtime_request.created", { request }), notice(2, "discovery-tool", "tool", { status: "pending" }),
|
||||
row(3, "tool.execution.started", start), notice(4, "discovery-tool", "permission_requested", { requestId: "discovery-request", declineOffered: true }),
|
||||
row(5, "runtime_request.resolved", { requestId: "discovery-request", turnId: "turn", requestKind: "permission_approval", action: "accept" }),
|
||||
notice(6, "discovery-tool", "permission_delivered", { requestId: "discovery-request", outcome: "allow_once" }),
|
||||
row(7, "provider.notice.recorded", receipt), notice(8, "discovery-tool", "tool", { status: "completed", semanticOperationId: "search_api", semanticCallIdentitySha256: values.callIdentitySha256,
|
||||
semanticInputSha256: inputHash, semanticNormalizedInputSha256: "null", semanticResultSha256: values.resultSha256, semanticOutcome: "returned" }),
|
||||
row(9, "tool.execution.completed", { ...start, status: "completed" })];
|
||||
f.events.unshift(...discovery);
|
||||
return f;
|
||||
}
|
||||
describe("optional exact context discovery", () => {
|
||||
it("requires a returned control-plane receipt before context and leaves the write unanswered", () => {
|
||||
const f = withDiscovery();
|
||||
expect(readCopilotContextRead(f.events, f.origin(), true)).toMatchObject({ discoveries: [{ toolCallId: "discovery-tool", permissionRequestId: "discovery-request", receiptSeq: 7 }] });
|
||||
expect(f.pending()).toMatchObject({ requestId: "edit-request" });
|
||||
});
|
||||
it.each([
|
||||
["another query", (rows: Row[]) => { payload(rows[2]).progress = "paperclip-search_api (pending): create_task"; }],
|
||||
["mutation projection", (rows: Row[]) => { payload(rows[2]).readOnly = false; }],
|
||||
["missing receipt", (rows: Row[]) => { rows.splice(6, 1); }],
|
||||
["wrong receipt operation", (rows: Row[]) => { setDetail(rows[6], "operationId", "write_document"); }],
|
||||
["rejected discovery", (rows: Row[]) => { setDetail(rows[6], "outcome", "error"); }],
|
||||
["native mismatch", (rows: Row[]) => { setDetail(rows[7], "semanticInputSha256", "c".repeat(64)); }],
|
||||
["session-wide approval", (rows: Row[]) => { setDetail(rows[5], "outcome", "allow_always"); }],
|
||||
["foreign bridge", (rows: Row[]) => { payload(rows[0]).request.origin.adapter = "untrusted"; }],
|
||||
["late discovery", (rows: Row[]) => { rows[8].seq = 11; }],
|
||||
["duplicate discovery", (rows: Row[]) => { rows.push(structuredClone(rows[6])); }],
|
||||
["borrowed context identity", (rows: Row[]) => { const contextReceipt = payload(rows[15]).details.find((d: Row) => d.name === "callIdentitySha256").value;
|
||||
setDetail(rows[6], "callIdentitySha256", contextReceipt); setDetail(rows[7], "semanticCallIdentitySha256", contextReceipt); }],
|
||||
] as const)("rejects %s", (_name, mutate) => {
|
||||
const f = withDiscovery(); mutate(f.events);
|
||||
expect(() => readCopilotContextRead(f.events, f.origin(), true)).toThrow();
|
||||
expect(f.pending).toThrow();
|
||||
});
|
||||
it("still refuses a stale mutation answer after provider death", () => {
|
||||
const f = withDiscovery(), pending = f.pending(); f.run.status = "failed"; f.issue.status = "blocked";
|
||||
f.events.push(row(23, "runtime_request.expired", { requestId: "edit-request", turnId: "turn", requestKind: "permission_approval" }), row(24, "turn.failed", { error: { code: "AGENT_DISCONNECTED" } }));
|
||||
expect(assertCopilotProviderDeath({ ...f, pending })).toMatchObject({ mutationReplay: false });
|
||||
f.events.push(notice(25, "edit-tool", "permission_delivered", { requestId: "edit-request", outcome: "allow_once" }));
|
||||
expect(() => assertCopilotProviderDeath({ ...f, pending })).toThrow();
|
||||
});
|
||||
it("settles Stop without mistaking completed discovery for an answered write", () => {
|
||||
const f = withDiscovery(), pending = f.pending(); f.run.status = "cancelled";
|
||||
f.events.push(row(23, "runtime_request.cancelled", { requestId: "edit-request", turnId: "turn", requestKind: "permission_approval", itemId: "edit-item", reason: "turn_terminal" }), row(24, "turn.cancelled", { status: "cancelled", error: null }));
|
||||
f.run.resultJson = { startupCancellation: { cancellationRequestId, requestedBy: { type: "board", userId: "local-board" } }, nativeCancellation: {
|
||||
schema: "paperclip.native-cancellation.v1", intentId: `native-cancellation:${cancellationRequestId}`, companyId: "company", runId: "run", issueId: "issue", scope: "run",
|
||||
dispatched: true, dispatchState: "acknowledged", reasonCode: "cancellation_run_only", effects: ["release_run_resources"], intentAuditId: "intent-audit", acknowledgementAuditId: "ack-audit" } };
|
||||
applyMainlineStopMetadata(f.run);
|
||||
const stopAcknowledgement = stopAcknowledgementForTest(f.run, cancellationRequestId, (BigInt(pending.observedMonotonicNs) + 1n).toString());
|
||||
expect(readActiveStopSettlement({ ...f, stopAcknowledgement, pending, dispatchMonotonicNs: (BigInt(pending.observedMonotonicNs) + 1n).toString() })).toMatchObject({ normalCompletionAccepted: false });
|
||||
f.events.push(row(25, "runtime_request.resolved", { requestId: "edit-request", action: "accept" }));
|
||||
expect(() => readActiveStopSettlement({ ...f, stopAcknowledgement, pending, dispatchMonotonicNs: (BigInt(pending.observedMonotonicNs) + 1n).toString() })).toThrow();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
function withTwoDiscoveries() {
|
||||
const f = withDiscovery();
|
||||
const second = JSON.parse(JSON.stringify(f.events.slice(0, 9)).replaceAll("discovery-", "second-discovery-")) as Row[];
|
||||
const advance = (r: Row, delta: number) => { r.seq += delta; r.sourceSeq += delta; frame(r).sourceSeq += delta; frame(r).sourceEventId = `source:run:${frame(r).sourceSeq}`; };
|
||||
for (const r of f.events.slice(9)) advance(r, 9);
|
||||
for (const r of second) {
|
||||
advance(r, 9);
|
||||
const p = payload(r);
|
||||
if (p.name === "paperclip-search_api") p.progress = "paperclip-search_api (pending): dedicated get_task_context tool";
|
||||
for (const d of p.details ?? []) {
|
||||
if (["callIdentitySha256", "semanticCallIdentitySha256"].includes(d.name)) d.value = "c".repeat(64);
|
||||
if (["inputSha256", "semanticInputSha256"].includes(d.name)) d.value = "d".repeat(64);
|
||||
if (["resultSha256", "semanticResultSha256"].includes(d.name)) d.value = "e".repeat(64);
|
||||
}
|
||||
}
|
||||
f.events.splice(9, 0, ...second);
|
||||
return f;
|
||||
}
|
||||
describe("multiple independently attested discovery decisions", () => {
|
||||
it("leaves only the mutation permission pending", () => {
|
||||
const f = withTwoDiscoveries();
|
||||
expect(readCopilotContextRead(f.events, f.origin(), true)?.discoveries).toHaveLength(2);
|
||||
expect(f.pending()).toMatchObject({ requestId: "edit-request" });
|
||||
});
|
||||
it.each(["stop", "provider-death"])("retains unanswered-write and no-replay guards after %s", kind => {
|
||||
const f = withTwoDiscoveries(), pending = f.pending();
|
||||
const seq = Math.max(...f.events.map(r => r.seq)) + 1;
|
||||
if (kind === "provider-death") {
|
||||
f.run.status = "failed"; f.issue.status = "blocked";
|
||||
f.events.push(row(seq, "runtime_request.expired", { requestId: "edit-request", turnId: "turn", requestKind: "permission_approval" }), row(seq + 1, "turn.failed", { error: { code: "AGENT_DISCONNECTED" } }));
|
||||
expect(assertCopilotProviderDeath({ ...f, pending })).toMatchObject({ mutationReplay: false });
|
||||
} else {
|
||||
f.run.status = "cancelled";
|
||||
applyMainlineStopMetadata(f.run);
|
||||
const dispatchMonotonicNs = (BigInt(pending.observedMonotonicNs) + 1n).toString();
|
||||
const stopAcknowledgement = stopAcknowledgementForTest(f.run, cancellationRequestId, dispatchMonotonicNs);
|
||||
f.events.push(row(seq, "runtime_request.cancelled", { requestId: "edit-request", turnId: "turn", requestKind: "permission_approval", itemId: "edit-item", reason: "turn_terminal" }), row(seq + 1, "turn.cancelled", { status: "cancelled", error: null }));
|
||||
const read = () => readActiveStopSettlement({ ...f, pending, dispatchMonotonicNs, stopAcknowledgement });
|
||||
expect(read()).toMatchObject({ normalCompletionAccepted: false });
|
||||
f.events.push(row(seq + 2, "runtime_request.resolved", { requestId: "edit-request", action: "accept" }));
|
||||
expect(read).toThrow();
|
||||
return;
|
||||
}
|
||||
f.events.push(notice(seq + 2, "edit-tool", "permission_delivered", { requestId: "edit-request", outcome: "allow_once" }));
|
||||
expect(() => assertCopilotProviderDeath({ ...f, pending })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readCopilotToolEvidence, type CopilotToolNotice } from "./copilot-evidence.js";
|
||||
import { hasAcpxNativeOrigin } from "./acpx-native-origin.js";
|
||||
|
||||
type Row = Record<string, any>;
|
||||
const rec = (v: unknown): Row => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Row : {};
|
||||
const receiptKeys = ["callIdentitySha256", "inputSha256", "normalizedInputSha256", "operationId", "outcome", "resultSha256", "schema", "stage"].sort().join(",");
|
||||
export interface CopilotBootstrapReadProof {
|
||||
toolCallId: string; pendingSeq: number; receiptSeq: number; completedSeq: number;
|
||||
canonicalSeqs: number[]; callIdentitySha256: string;
|
||||
permissionRequestId?: string;
|
||||
}
|
||||
|
||||
export interface CopilotContextReadProof extends CopilotBootstrapReadProof { discoveries: CopilotBootstrapReadProof[] }
|
||||
|
||||
export const COPILOT_CONTEXT_DISCOVERY_LIMIT = 4;
|
||||
/** Metadata discovery only. Keep a small lexical scope and require the context
|
||||
* identifier; arbitrary API searches, pagination loops and mutations are not
|
||||
* bootstrap actions for these authored permission/settlement cases. */
|
||||
export function isCopilotContextDiscoveryProgress(value: unknown): boolean {
|
||||
const prefix = "paperclip-search_api (pending): ";
|
||||
if (typeof value !== "string" || !value.startsWith(prefix) || value.length > 120) return false;
|
||||
// Retained v17 native discovery used this exact context-only natural spelling.
|
||||
if (value === `${prefix}get task context`) return true;
|
||||
const terms = value.slice(prefix.length).split(/\s+/u);
|
||||
return terms.length > 0 && terms.length <= 4 && terms.includes("get_task_context")
|
||||
&& new Set(terms).size === terms.length && terms.every(t => ["get_task_context", "paperclip_finish", "dedicated", "tool"].includes(t));
|
||||
}
|
||||
|
||||
/** Attest context and any exact read-only tool discovery before the native action. */
|
||||
export function readCopilotContextRead(rows: readonly unknown[], origin: CopilotToolNotice, required = false): CopilotContextReadProof | undefined {
|
||||
const context = readSemanticBootstrap(rows, origin, required, "get_task_context");
|
||||
if (!context) return undefined;
|
||||
const pending = readCopilotToolEvidence(rows, origin.runId).find(n => n.seq === context.pendingSeq)!;
|
||||
const searchReceipts = rows.map(rec).filter(r => {
|
||||
const p = rec(rec(rec(r.payload).prpEvent).payload);
|
||||
return p.category === "paperclip_semantic_tool_receipt_v2" && Array.isArray(p.details)
|
||||
&& p.details.some((d: Row) => d.name === "operationId" && d.value === "search_api");
|
||||
});
|
||||
const nativeSearches = readCopilotToolEvidence(rows, origin.runId).filter(n => n.semanticOperationId === "search_api");
|
||||
if (searchReceipts.length > COPILOT_CONTEXT_DISCOVERY_LIMIT || searchReceipts.length !== nativeSearches.length) throw new Error("Copilot context discovery is unbounded or incomplete");
|
||||
const discoveries = searchReceipts.map(r => {
|
||||
const fields = rec(rec(rec(r.payload).prpEvent).payload).details as Row[];
|
||||
const identity = fields.find(d => d.name === "callIdentitySha256")?.value;
|
||||
if (typeof identity !== "string") throw new Error("Copilot discovery lacks its invocation identity");
|
||||
return readSemanticBootstrap(rows, pending, true, "search_api", identity)!;
|
||||
}).sort((a, b) => a.pendingSeq - b.pendingSeq);
|
||||
const identities = [context.callIdentitySha256, ...discoveries.map(d => d.callIdentitySha256)];
|
||||
if (new Set(identities).size !== identities.length) throw new Error("Copilot discovery cannot borrow another call identity");
|
||||
return { ...context, discoveries };
|
||||
}
|
||||
function readSemanticBootstrap(rows: readonly unknown[], origin: CopilotToolNotice, required: boolean, operationId: "get_task_context" | "search_api", selectedIdentity?: string): CopilotBootstrapReadProof | undefined {
|
||||
const discovery = operationId === "search_api";
|
||||
const fail = (): never => { throw new Error("Copilot context read lacks exact successful pre-action proof"); };
|
||||
const all = rows.map(rec), notices = readCopilotToolEvidence(rows, origin.runId);
|
||||
const matches = all.filter(r => {
|
||||
const p = rec(rec(rec(r.payload).prpEvent).payload);
|
||||
return p.category === "paperclip_semantic_tool_receipt_v2" && Array.isArray(p.details)
|
||||
&& p.details.some((d: any) => d?.name === "operationId" && d.value === operationId)
|
||||
&& (!selectedIdentity || p.details.some((d: any) => d?.name === "callIdentitySha256" && d.value === selectedIdentity));
|
||||
});
|
||||
const native = notices.filter(n => n.semanticOperationId === operationId && (!selectedIdentity || n.semanticCallIdentitySha256 === selectedIdentity));
|
||||
if (!matches.length && !native.length && !required) return undefined;
|
||||
if (matches.length !== 1 || native.length !== 1) fail();
|
||||
const roots = all.filter(r => r.seq === origin.seq);
|
||||
if (roots.length !== 1) fail();
|
||||
const root = roots[0]!, base = rec(rec(root.payload).prpEvent);
|
||||
const frame = (r: Row) => {
|
||||
const e = rec(rec(r.payload).prpEvent);
|
||||
if (r.companyId !== root.companyId || r.runId !== origin.runId || !Number.isSafeInteger(r.seq) || r.seq < 1
|
||||
|| e.schema !== "paperclip.prp.event.v1" || e.sourceKind !== "runner" || e.eventType !== r.eventType
|
||||
|| e.runId !== origin.runId || e.turnId !== origin.turnId || e.normalizedSessionId !== base.normalizedSessionId
|
||||
|| typeof base.normalizedSessionId !== "string" || !base.normalizedSessionId || e.sourceInstanceId !== base.sourceInstanceId
|
||||
|| typeof base.sourceInstanceId !== "string" || !base.sourceInstanceId || r.sourceInstanceId !== e.sourceInstanceId
|
||||
|| r.sourceSeq !== e.sourceSeq || !Number.isSafeInteger(e.sourceSeq) || e.sourceSeq < 1) fail();
|
||||
return e;
|
||||
};
|
||||
frame(root);
|
||||
const authority = matches[0]!, receipt = rec(frame(authority).payload), provenance = rec(receipt.provenance);
|
||||
if (authority.eventType !== "provider.notice.recorded" || receipt.schema !== "paperclip.provider.notice.v1" || receipt.scope !== "turn"
|
||||
|| provenance.method !== "paperclip/semantic_tool_result" || provenance.eventType !== "semantic_result"
|
||||
|| provenance.sessionId !== origin.sessionId || provenance.turnId !== origin.turnId || receipt.details.length !== 8) fail();
|
||||
const fields: Record<string, string> = {};
|
||||
for (const d of receipt.details) {
|
||||
if (Object.keys(rec(d)).sort().join(",") !== "name,value" || typeof d.name !== "string" || typeof d.value !== "string" || d.value.length > 256 || Object.hasOwn(fields, d.name)) fail();
|
||||
fields[d.name] = d.value;
|
||||
}
|
||||
if (Object.keys(fields).sort().join(",") !== receiptKeys || fields.stage !== "semantic_result" || fields.schema !== "paperclip.semantic_tool_receipt.v2"
|
||||
|| fields.operationId !== operationId || fields.outcome !== "returned" || fields.normalizedInputSha256 !== "null"
|
||||
|| (discovery ? !/^[a-f0-9]{64}$/.test(fields.inputSha256!) : fields.inputSha256 !== createHash("sha256").update("{}").digest("hex"))
|
||||
|| ![fields.callIdentitySha256, fields.resultSha256].every(v => /^[a-f0-9]{64}$/.test(v!))) fail();
|
||||
const completed = native[0]!, contextNotices = notices.filter(n => n.toolCallId === completed.toolCallId);
|
||||
const group = contextNotices.filter(n => n.stage === "tool").sort((a, b) => a.seq - b.seq);
|
||||
const permissions = contextNotices.filter(n => n.stage !== "tool");
|
||||
let permissionRequestId: string | undefined;
|
||||
if (permissions.length) {
|
||||
const requested = permissions.filter(n => n.stage === "permission_requested"), delivered = permissions.filter(n => n.stage === "permission_delivered");
|
||||
if (permissions.length !== 2 || requested.length !== 1 || delivered.length !== 1 || !requested[0]!.requestId
|
||||
|| delivered[0]!.requestId !== requested[0]!.requestId || delivered[0]!.outcome !== "allow_once"
|
||||
|| requested[0]!.seq >= delivered[0]!.seq || delivered[0]!.seq >= authority.seq) fail();
|
||||
permissionRequestId = requested[0]!.requestId;
|
||||
for (const n of permissions) {
|
||||
const rs = all.filter(r => r.seq === n.seq);
|
||||
if (rs.length !== 1 || n.runId !== origin.runId || n.sessionId !== origin.sessionId || n.turnId !== origin.turnId
|
||||
|| n.operation !== undefined || n.target !== undefined || n.commandSha256 !== undefined || n.shellId !== undefined) fail();
|
||||
frame(rs[0]!);
|
||||
}
|
||||
const requests = all.filter(r => r.eventType === "runtime_request.created" && rec(rec(rec(r.payload).prpEvent).payload).request?.requestId === permissionRequestId);
|
||||
const closures = all.filter(r => ["runtime_request.resolved", "runtime_request.cancelled", "runtime_request.expired"].includes(r.eventType)
|
||||
&& rec(rec(rec(r.payload).prpEvent).payload).requestId === permissionRequestId);
|
||||
if (requests.length !== 1 || closures.length !== 1) fail();
|
||||
// The current sidecar emits its native notice before the durable card.
|
||||
// Both origins must precede the exact closure; neither can borrow a later decision.
|
||||
const request = rec(rec(frame(requests[0]!).payload).request), resolution = rec(frame(closures[0]!).payload);
|
||||
if (request.schema !== "paperclip.runtime_request.v2" || request.type !== "permission" || request.requestKind !== "permission_approval"
|
||||
|| request.prompt !== operationId || request.turnId !== origin.turnId || request.status !== "pending"
|
||||
|| !(hasAcpxNativeOrigin(request.origin, "copilot", "session/request_permission") || hasAcpxNativeOrigin(request.origin, "acpx", "session/request_permission")) || resolution.action !== "accept"
|
||||
|| resolution.requestKind !== "permission_approval" || resolution.turnId !== origin.turnId || closures[0]!.eventType !== "runtime_request.resolved"
|
||||
|| requests[0]!.seq >= closures[0]!.seq || requested[0]!.seq >= closures[0]!.seq || closures[0]!.seq >= delivered[0]!.seq) fail();
|
||||
}
|
||||
if (group.length < 2 || group.length > 16 || completed.status !== "completed" || group.at(-1) !== completed
|
||||
|| completed.semanticOutcome !== "returned" || completed.semanticCallIdentitySha256 !== fields.callIdentitySha256
|
||||
|| completed.semanticInputSha256 !== fields.inputSha256 || completed.semanticResultSha256 !== fields.resultSha256 || completed.semanticNormalizedInputSha256 !== null
|
||||
|| group.some((n, i) => n.stage !== "tool" || n.runId !== origin.runId || n.sessionId !== origin.sessionId || n.turnId !== origin.turnId
|
||||
|| n.operation !== (discovery ? undefined : "read") || n.target !== undefined || n.commandSha256 !== undefined || n.readTargetSha256 !== undefined || n.shellId !== undefined
|
||||
|| n.commandToolCallId !== undefined || n.seq >= origin.seq || (i === 0 ? n.status !== "pending" : i === group.length - 1 ? n.status !== "completed" : n.status !== "in_progress"))) fail();
|
||||
for (const n of group) {
|
||||
const rs = all.filter(r => r.seq === n.seq); if (rs.length !== 1) fail(); frame(rs[0]!);
|
||||
}
|
||||
const canonical = all.filter(r => r.eventType?.startsWith("tool.execution.") && rec(rec(rec(r.payload).prpEvent).payload).executionId === completed.toolCallId).sort((a, b) => a.seq - b.seq);
|
||||
if (canonical.length < 2 || canonical.length > 16 || new Set(canonical.map(r => r.seq)).size !== canonical.length) fail();
|
||||
for (let i = 0; i < canonical.length; i++) {
|
||||
const r = canonical[i]!, p = rec(frame(r).payload), last = i === canonical.length - 1;
|
||||
if (r.seq >= origin.seq || p.schema !== "paperclip.tool.execution.v1" || p.transport !== "builtin" || p.target !== null
|
||||
|| p.name !== `paperclip-${operationId}` || p.operation !== (discovery ? "search" : "read") || p.readOnly !== true
|
||||
|| (last ? r.eventType !== "tool.execution.completed" || p.status !== "completed"
|
||||
: p.status !== "running" || r.eventType !== (i === 0 ? "tool.execution.started" : "tool.execution.progressed"))) fail();
|
||||
}
|
||||
if (discovery && !isCopilotContextDiscoveryProgress(rec(frame(canonical[0]!).payload).progress)) fail();
|
||||
const pending = group[0]!;
|
||||
if (!(pending.seq < canonical[0]!.seq && canonical[0]!.seq < authority.seq && authority.seq < completed.seq && completed.seq < canonical.at(-1)!.seq
|
||||
&& canonical.at(-1)!.seq < origin.seq) || !(frame(all.find(r => r.seq === pending.seq)!).sourceSeq < frame(authority).sourceSeq
|
||||
&& frame(authority).sourceSeq < frame(all.find(r => r.seq === completed.seq)!).sourceSeq && frame(canonical.at(-1)!).sourceSeq < base.sourceSeq)) fail();
|
||||
return { toolCallId: completed.toolCallId, pendingSeq: pending.seq, receiptSeq: authority.seq, completedSeq: completed.seq,
|
||||
canonicalSeqs: canonical.map(r => r.seq), callIdentitySha256: fields.callIdentitySha256!, ...(permissionRequestId ? { permissionRequestId } : {}) };
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { pendingCopilotContextPermission, prepareCopilotContext } from "./copilot-context-permission.js";
|
||||
|
||||
describe("Copilot initial context permission fixture", () => {
|
||||
it("recognizes the current native Copilot origin from the failed v26 denial attempt", async () => {
|
||||
const retained = JSON.parse(await readFile(new URL("./fixtures/copilot-context-permission-v26.json", import.meta.url), "utf8"));
|
||||
const { rows } = retained, company = rows[0].companyId, run = rows[0].runId;
|
||||
expect(retained.provenance.originalGrade).toContain("failed");
|
||||
expect(pendingCopilotContextPermission(rows, company, run)).toMatchObject({ operationId: "get_task_context", runId: run });
|
||||
for (const provider of ["cursor", "pi", "claude", "codex", "unknown"]) {
|
||||
const bad = structuredClone(rows);
|
||||
bad.find((row: any) => row.eventType === "runtime_request.created").payload.prpEvent.payload.request.origin.provider = provider;
|
||||
expect(() => pendingCopilotContextPermission(bad, company, run)).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("recognizes the retained v17 natural context lookup without claiming provider-death qualification", async () => {
|
||||
const retained = JSON.parse(await readFile(new URL("./fixtures/copilot-context-discovery-permission-v17.json", import.meta.url), "utf8"));
|
||||
const { rows } = retained, company = rows[0].companyId, run = rows[0].runId;
|
||||
expect(retained.outcome).toContain("failed");
|
||||
expect(pendingCopilotContextPermission(rows, company, run)).toMatchObject({ operationId: "search_api", runId: run });
|
||||
for (const scope of ["get task context create_task", "get task context call_api", "get task context; paperclip_finish"]) {
|
||||
const bad = structuredClone(rows);
|
||||
bad.find((row: any) => row.eventType === "tool.execution.started").payload.prpEvent.payload.progress = `paperclip-search_api (pending): ${scope}`;
|
||||
expect(() => pendingCopilotContextPermission(bad, company, run)).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("recognizes the exact retained unanswered context card without approving another operation", async () => {
|
||||
const { rows } = JSON.parse(await readFile(new URL("./fixtures/copilot-context-permission-v15.json", import.meta.url), "utf8"));
|
||||
const company = rows[0].companyId, run = rows[0].runId;
|
||||
expect(pendingCopilotContextPermission(rows, company, run)).toMatchObject({ runId: run, requestId: expect.any(String), toolCallId: expect.any(String) });
|
||||
for (const kind of ["mutation", "other-prompt", "not-read-only", "foreign-company", "foreign-source", "foreign-bridge", "missing-decline", "duplicate", "unknown-target", "missing-read-origin"]) {
|
||||
const bad = structuredClone(rows);
|
||||
const payload = (r: any) => r.payload.prpEvent.payload;
|
||||
if (kind === "mutation") payload(bad[2]).operation = "edit";
|
||||
if (kind === "other-prompt") payload(bad[0]).request.prompt = "report_progress";
|
||||
if (kind === "not-read-only") payload(bad[2]).readOnly = false;
|
||||
if (kind === "foreign-company") bad[2].companyId = "foreign";
|
||||
if (kind === "foreign-source") bad[2].payload.prpEvent.sourceInstanceId = "foreign";
|
||||
if (kind === "foreign-bridge") payload(bad[0]).request.origin.adapter = "untrusted";
|
||||
if (kind === "missing-decline") payload(bad[0]).request.choices = [{ key: "accept" }];
|
||||
if (kind === "duplicate") bad.push(structuredClone(bad[0]));
|
||||
if (kind === "unknown-target") payload(bad[1]).details.push({ name: "target", value: "other.txt" });
|
||||
if (kind === "missing-read-origin") bad.splice(2, 1);
|
||||
expect(() => pendingCopilotContextPermission(bad, company, run), kind).toThrow();
|
||||
}
|
||||
});
|
||||
it("waits for the native notice when the durable request arrives first", async () => {
|
||||
const { rows } = JSON.parse(await readFile(new URL("./fixtures/copilot-context-permission-v15.json", import.meta.url), "utf8"));
|
||||
expect(pendingCopilotContextPermission(rows.slice(0, 3), rows[0].companyId, rows[0].runId)).toBeUndefined();
|
||||
expect(pendingCopilotContextPermission([], rows[0].companyId, rows[0].runId)).toBeUndefined();
|
||||
});
|
||||
it("recognizes retained discovery only for the exact read-only context lookup", async () => {
|
||||
const { rows } = JSON.parse(await readFile(new URL("./fixtures/copilot-context-discovery-permission-v15.json", import.meta.url), "utf8"));
|
||||
const company = rows[0].companyId, run = rows[0].runId;
|
||||
expect(pendingCopilotContextPermission(rows, company, run)).toMatchObject({ operationId: "search_api", runId: run });
|
||||
for (const kind of ["other-query", "mutation", "not-read-only", "foreign-source", "unknown-target", "duplicate-permission"]) {
|
||||
const bad = structuredClone(rows), p = (r: any) => r.payload.prpEvent.payload;
|
||||
if (kind === "other-query") p(bad[2]).progress = "paperclip-search_api (pending): create_task";
|
||||
if (kind === "mutation") p(bad[2]).operation = "edit";
|
||||
if (kind === "not-read-only") p(bad[2]).readOnly = false;
|
||||
if (kind === "foreign-source") bad[1].sourceSeq += 1;
|
||||
if (kind === "unknown-target") p(bad[1]).details.push({ name: "target", value: "other.txt" });
|
||||
if (kind === "duplicate-permission") bad.push(structuredClone(bad[3]));
|
||||
expect(() => pendingCopilotContextPermission(bad, company, run), kind).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("remote action publication before Copilot context approval", () => {
|
||||
it("keeps the context card unanswered until the observer and action file are ready", async () => {
|
||||
let release!: () => void;
|
||||
const ready = new Promise<void>(resolve => { release = resolve; });
|
||||
let observerArmed = false, actionPublished = false;
|
||||
const approveContext = vi.fn(async () => {
|
||||
expect(observerArmed).toBe(true);
|
||||
expect(actionPublished).toBe(true);
|
||||
});
|
||||
const pending = prepareCopilotContext({ remoteSetup: async () => {
|
||||
observerArmed = true;
|
||||
await ready;
|
||||
actionPublished = true;
|
||||
}, approveContext });
|
||||
await Promise.resolve();
|
||||
expect(approveContext).not.toHaveBeenCalled();
|
||||
release();
|
||||
await pending;
|
||||
expect(approveContext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not release the provider when remote setup fails", async () => {
|
||||
const approveContext = vi.fn();
|
||||
await expect(prepareCopilotContext({ remoteSetup: async () => { throw new Error("observer not armed"); }, approveContext })).rejects.toThrow("observer not armed");
|
||||
expect(approveContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("approves local context without waiting for a remote fixture", async () => {
|
||||
const approveContext = vi.fn(async () => {});
|
||||
await prepareCopilotContext({ approveContext });
|
||||
expect(approveContext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { COPILOT_CONTEXT_DISCOVERY_LIMIT, isCopilotContextDiscoveryProgress } from "./copilot-context-evidence.js";
|
||||
import { copilotPendingPermissionCard } from "./copilot-permission-card.js";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { pollUntil } from "./api.js";
|
||||
import { readCopilotToolEvidence } from "./copilot-evidence.js";
|
||||
import { hasAcpxNativeOrigin } from "./acpx-native-origin.js";
|
||||
|
||||
type Row = Record<string, any>;
|
||||
const record = (value: unknown): Row => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Row : {};
|
||||
|
||||
/** Fixture human decision, never an automatic production permission rule. */
|
||||
export function pendingCopilotContextPermission(events: readonly unknown[], companyId: string, runId: string) {
|
||||
const rows = events.map(record), notices = readCopilotToolEvidence(events, runId);
|
||||
const closed = new Set(rows.filter(r => ["runtime_request.resolved", "runtime_request.cancelled", "runtime_request.expired"].includes(r.eventType))
|
||||
.map(r => record(record(record(r.payload).prpEvent).payload).requestId));
|
||||
const pending = rows.filter(r => r.eventType === "runtime_request.created" && !closed.has(record(record(record(r.payload).prpEvent).payload).request?.requestId));
|
||||
if (!pending.length) return undefined;
|
||||
if (pending.length !== 1) throw new Error("Context fixture requires one exact pending request");
|
||||
const row = pending[0]!, frame = record(record(row.payload).prpEvent), request = record(record(frame.payload).request);
|
||||
const operationId = request.prompt;
|
||||
if (operationId !== "get_task_context" && operationId !== "search_api") throw new Error("Context fixture cannot approve another operation");
|
||||
const discovery = operationId === "search_api";
|
||||
const permissions = notices.filter(n => n.stage === "permission_requested" && n.requestId === request.requestId);
|
||||
if (!permissions.length) return undefined; // Native notice can follow the durable card.
|
||||
if (permissions.length !== 1 || !permissions[0]!.declineOffered) throw new Error("Context fixture permission is ambiguous");
|
||||
const native = permissions[0]!, group = notices.filter(n => n.toolCallId === native.toolCallId);
|
||||
const started = group.filter(n => n.stage === "tool" && n.status === "pending" && n.operation === (discovery ? undefined : "read"));
|
||||
const canonical = rows.filter(r => r.eventType === "tool.execution.started" && record(record(record(r.payload).prpEvent).payload).executionId === native.toolCallId);
|
||||
if (started.length !== 1 || canonical.length !== 1 || group.length !== 2
|
||||
|| group.some(n => n.target !== undefined || n.commandSha256 !== undefined || n.shellId !== undefined || n.readTargetSha256 !== undefined)) throw new Error("Context fixture lacks an exact read-only origin");
|
||||
for (const r of [row, canonical[0]!, ...group.map(n => rows.find(r => r.seq === n.seq)!)]) {
|
||||
const e = record(record(r?.payload).prpEvent);
|
||||
if (r?.companyId !== companyId || r.runId !== runId || e.schema !== "paperclip.prp.event.v1" || e.sourceKind !== "runner"
|
||||
|| e.eventType !== r.eventType || e.runId !== runId || e.turnId !== native.turnId || e.normalizedSessionId !== frame.normalizedSessionId
|
||||
|| e.sourceInstanceId !== frame.sourceInstanceId || r.sourceInstanceId !== e.sourceInstanceId || r.sourceSeq !== e.sourceSeq
|
||||
|| !Number.isSafeInteger(e.sourceSeq) || !Number.isSafeInteger(r.seq)) throw new Error("Context fixture has foreign durable evidence");
|
||||
}
|
||||
const tool = record(record(record(canonical[0]!.payload).prpEvent).payload);
|
||||
if (tool.name !== `paperclip-${operationId}` || tool.operation !== (discovery ? "search" : "read") || tool.readOnly !== true || tool.target !== null || tool.transport !== "builtin"
|
||||
|| request.schema !== "paperclip.runtime_request.v2" || request.type !== "permission" || request.status !== "pending"
|
||||
|| request.requestKind !== "permission_approval" || request.turnId !== native.turnId || request.method !== "session/request_permission"
|
||||
|| !(hasAcpxNativeOrigin(request.origin, "copilot", "session/request_permission") || hasAcpxNativeOrigin(request.origin, "acpx", "session/request_permission"))
|
||||
|| !Array.isArray(request.choices) || !["accept", "decline"].every(key => request.choices.some((c: Row) => c.key === key))) throw new Error("Context fixture cannot approve this card");
|
||||
if (discovery && !isCopilotContextDiscoveryProgress(tool.progress)) throw new Error("Context discovery must search only for the required context tool");
|
||||
return { operationId, runId, requestId: native.requestId!, turnId: native.turnId, toolCallId: native.toolCallId };
|
||||
}
|
||||
|
||||
export async function approveCopilotContextThroughUi(input: {
|
||||
page: Page; companyId: string; deadlineAt: number;
|
||||
load(): Promise<{ run: Row; events: readonly unknown[] }>;
|
||||
evidence(name: string, data: unknown): Promise<void>;
|
||||
}) {
|
||||
const label = "initial Copilot context permission", decided = new Set<string>();
|
||||
for (let index = 0; index <= COPILOT_CONTEXT_DISCOVERY_LIMIT; index++) {
|
||||
const result = await pollUntil({ label, deadlineAt: input.deadlineAt, intervalMs: 200,
|
||||
load: async () => {
|
||||
const state = await input.load();
|
||||
if (["failed", "timed_out", "cancelled", "succeeded"].includes(state.run.status)) throw new Error(`Stopped waiting for ${label}: run ended`);
|
||||
if (!state.run.id) return undefined;
|
||||
const notices = readCopilotToolEvidence(state.events, state.run.id);
|
||||
if (notices.some(n => n.semanticOperationId === "get_task_context" && n.status === "completed" && n.semanticOutcome === "returned")) return { completed: true as const };
|
||||
try {
|
||||
const pending = pendingCopilotContextPermission(state.events, input.companyId, state.run.id);
|
||||
if (pending && decided.has(pending.requestId)) return undefined; // Await durable closure after the browser POST.
|
||||
return pending ? { pending } : undefined;
|
||||
} catch (error) { throw new Error(`Stopped waiting for ${label}: ${error instanceof Error ? error.message : "invalid request"}`); }
|
||||
}, accept: Boolean });
|
||||
if (!result || "completed" in result) return;
|
||||
const { pending } = result;
|
||||
if (decided.has(pending.requestId)) throw new Error("Context fixture cannot replay a bootstrap decision");
|
||||
decided.add(pending.requestId);
|
||||
const card = copilotPendingPermissionCard(input.page, pending.operationId);
|
||||
await expect(card).toHaveCount(1); await expect(card).toContainText(pending.operationId);
|
||||
const path = `/api/heartbeat-runs/${pending.runId}/runtime-requests/${encodeURIComponent(pending.requestId)}/resolve`;
|
||||
const sent = input.page.waitForRequest(r => new URL(r.url()).pathname === path && r.method() === "POST");
|
||||
await card.getByRole("button", { name: "Allow once", exact: true }).click();
|
||||
const body = (await sent).postDataJSON();
|
||||
if (body.turnId !== pending.turnId || body.requestKind !== "permission_approval" || body.resolution?.action !== "accept") throw new Error("Context fixture submitted a foreign decision");
|
||||
await input.evidence(`copilot-initial-${index}-${pending.operationId}-decision.json`, { schema: "paperclip.e2e.copilot-context-decision.v1", ...pending, action: "accept", source: "normal browser permission card; fixture human decision" });
|
||||
if (pending.operationId === "get_task_context") return;
|
||||
}
|
||||
throw new Error("Context fixture exhausted its bounded bootstrap decisions");
|
||||
}
|
||||
|
||||
/** Publish the observed remote action while the provider can still be waiting
|
||||
* on its context card. Waiting for that card first leaves the setup file absent
|
||||
* throughout the provider's bounded read window. Never approve another tool. */
|
||||
export async function prepareCopilotContext(input: {
|
||||
remoteSetup?: () => Promise<void>;
|
||||
approveContext(): Promise<void>;
|
||||
}): Promise<void> {
|
||||
await input.remoteSetup?.();
|
||||
await input.approveContext();
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { COPILOT_CONTEXT_DISCOVERY_LIMIT, isCopilotContextDiscoveryProgress, readCopilotContextRead } from "./copilot-context-evidence.js";
|
||||
import { readCopilotToolEvidence } from "./copilot-evidence.js";
|
||||
import { onlyCopilotAttachedOperations, readCopilotSemanticCompletion } from "./copilot-semantic-evidence.js";
|
||||
|
||||
const retained = JSON.parse(readFileSync(new URL("./fixtures/copilot-attached-discovery-sequence-v15.json", import.meta.url), "utf8"));
|
||||
function fixture() {
|
||||
const rows = structuredClone(retained.rows);
|
||||
const proposed = rows.find((r: any) => r.eventType === "run.result.proposed");
|
||||
const frame = proposed.payload.prpEvent;
|
||||
const notices = readCopilotToolEvidence(rows, proposed.runId);
|
||||
const command = notices.find(n => n.stage === "tool" && n.status === "pending" && n.mode === "async")!;
|
||||
const expected = { companyId: proposed.companyId, runId: proposed.runId, turnId: frame.turnId,
|
||||
nativeSessionId: command.sessionId, command, summary: frame.payload.summary, requireContextRead: true };
|
||||
return { rows, notices, command, expected };
|
||||
}
|
||||
const payload = (r: any) => r.payload.prpEvent.payload;
|
||||
const detail = (r: any, name: string, value: string) => { payload(r).details.find((d: any) => d.name === name).value = value; };
|
||||
describe("bounded individually attested context discovery sequence", () => {
|
||||
it("calibrates the retained two-discovery completion without treating the failed live attempt as qualified", () => {
|
||||
const f = fixture(), context = readCopilotContextRead(f.rows, f.command, true)!;
|
||||
expect(context.discoveries).toHaveLength(2);
|
||||
expect(context.discoveries[0]!.canonicalSeqs.at(-1)!).toBeLessThan(context.discoveries[1]!.pendingSeq);
|
||||
expect(context.discoveries[1]!.canonicalSeqs.at(-1)!).toBeLessThan(context.pendingSeq);
|
||||
const proof = readCopilotSemanticCompletion(f.rows, f.expected);
|
||||
expect(proof.contextRead?.discoveries).toHaveLength(2);
|
||||
expect(onlyCopilotAttachedOperations(f.notices, f.command, proof)).toBe(true);
|
||||
expect(retained.outcome).toContain("calibration only");
|
||||
});
|
||||
it.each(["get_task_context", "get task context", "dedicated get_task_context tool", "get_task_context paperclip_finish"])("admits bounded context metadata query %s", query => {
|
||||
expect(isCopilotContextDiscoveryProgress(`paperclip-search_api (pending): ${query}`)).toBe(true);
|
||||
});
|
||||
it.each(["create_task", "get task context call_api", "get task context create_task", "get task context; paperclip_finish", "get_task_context call_api", "get_task_context get_task_context", "get_task_context; paperclip_finish", "get_task_context " + "tool ".repeat(100)])("refuses another search scope: %s", query => {
|
||||
expect(isCopilotContextDiscoveryProgress(`paperclip-search_api (pending): ${query}`)).toBe(false);
|
||||
});
|
||||
it.each(["duplicate-receipt", "missing-receipt", "borrowed-identity", "foreign-session", "mutation", "another-query", "native-result-mismatch", "late-discovery"])("refuses %s in a multi-call sequence", kind => {
|
||||
const f = fixture();
|
||||
const receipts = f.rows.filter((r: any) => payload(r).category === "paperclip_semantic_tool_receipt_v2" && payload(r).details.some((d: any) => d.name === "operationId" && d.value === "search_api"));
|
||||
const second = receipts[1]!;
|
||||
const start = f.rows.find((r: any) => r.eventType === "tool.execution.started" && payload(r).name === "paperclip-search_api");
|
||||
if (kind === "duplicate-receipt") f.rows.push(structuredClone(second));
|
||||
if (kind === "missing-receipt") f.rows.splice(f.rows.indexOf(second), 1);
|
||||
if (kind === "borrowed-identity") detail(second, "callIdentitySha256", payload(receipts[0]).details.find((d: any) => d.name === "callIdentitySha256").value);
|
||||
if (kind === "foreign-session") second.payload.prpEvent.normalizedSessionId = "foreign";
|
||||
if (kind === "mutation") payload(start).readOnly = false;
|
||||
if (kind === "another-query") payload(start).progress = "paperclip-search_api (pending): create_task";
|
||||
if (kind === "native-result-mismatch") detail(second, "resultSha256", "f".repeat(64));
|
||||
if (kind === "late-discovery") second.seq = f.command.seq + 1;
|
||||
expect(() => readCopilotContextRead(f.rows, f.command, true)).toThrow();
|
||||
});
|
||||
it("refuses an unbounded discovery campaign", () => {
|
||||
const f = fixture(), receipt = f.rows.find((r: any) => payload(r).category === "paperclip_semantic_tool_receipt_v2" && payload(r).details.some((d: any) => d.name === "operationId" && d.value === "search_api"));
|
||||
for (let i = 0; i < COPILOT_CONTEXT_DISCOVERY_LIMIT; i++) f.rows.push(structuredClone(receipt));
|
||||
expect(() => readCopilotContextRead(f.rows, f.command, true)).toThrow(/unbounded/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/** Strict reader for bounded notices persisted through the public run-event API. */
|
||||
export interface CopilotToolNotice {
|
||||
runId: string; sessionId: string; turnId: string; toolCallId: string;
|
||||
observedAtMs: number; seq: number;
|
||||
semanticOperationId?: string; semanticCallIdentitySha256?: string; semanticInputSha256?: string; semanticNormalizedInputSha256?: string | null; semanticResultSha256?: string; semanticOutcome?: "returned" | "error";
|
||||
stage: "tool" | "permission_requested" | "permission_delivered";
|
||||
status?: "pending" | "in_progress" | "completed" | "failed";
|
||||
operation?: "edit" | "execute" | "read"; target?: string; requestId?: string;
|
||||
declineOffered?: boolean; outcome?: "allow_once" | "allow_always" | "reject_once" | "cancel";
|
||||
commandSha256?: string; readTargetSha256?: string; mode?: "sync" | "async"; detach?: boolean;
|
||||
shellId?: string; commandToolCallId?: string; shellState?: "started" | "completed"; exitCode?: number;
|
||||
}
|
||||
const rec = (v: unknown): Record<string, any> => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Record<string, any> : {};
|
||||
const id = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(v) && !v.includes("[REDACTED]");
|
||||
const enums = { stage: ["tool", "permission_requested", "permission_delivered"], status: ["pending", "in_progress", "completed", "failed"], operation: ["edit", "execute", "read"], outcome: ["allow_once", "allow_always", "reject_once", "cancel"], mode: ["sync", "async"], shellState: ["started", "completed"] };
|
||||
const names = new Set(["stage", "toolCallId", "status", "operation", "target", "requestId", "declineOffered", "outcome", "commandSha256", "readTargetSha256", "mode", "detach", "shellId", "commandToolCallId", "shellState", "exitCode", "semanticOperationId", "semanticCallIdentitySha256", "semanticInputSha256", "semanticNormalizedInputSha256", "semanticResultSha256", "semanticOutcome"]);
|
||||
export function readCopilotToolEvidence(rows: readonly unknown[], expectedRunId: string): CopilotToolNotice[] {
|
||||
const result: CopilotToolNotice[] = [];
|
||||
for (const row of rows) {
|
||||
const r = rec(row), envelope = rec(rec(r.payload).prpEvent), p = rec(envelope.payload);
|
||||
if (r.eventType !== "provider.notice.recorded" || p.category !== "copilot_tool_evidence_v1") continue;
|
||||
const origin = rec(p.provenance);
|
||||
if (p.schema !== "paperclip.provider.notice.v1" || p.scope !== "turn" || envelope.schema !== "paperclip.prp.event.v1" || envelope.sourceKind !== "runner" || envelope.eventType !== r.eventType || envelope.runId !== expectedRunId || !id(origin.sessionId) || !id(origin.turnId) || origin.turnId !== envelope.turnId || !Array.isArray(p.details) || p.details.length > 20 || !Number.isSafeInteger(r.seq)) throw new Error("Invalid Copilot evidence binding");
|
||||
const fields: Record<string, string> = {};
|
||||
for (const detail of p.details) {
|
||||
const d = rec(detail);
|
||||
if (d.name === "stage" && d.value === "evidence_incomplete") throw new Error("Copilot evidence is explicitly incomplete");
|
||||
if (!names.has(d.name) || Object.hasOwn(fields, d.name) || typeof d.value !== "string" || d.value.length > 1024 || d.value.includes("[REDACTED]")) throw new Error("Invalid Copilot evidence detail");
|
||||
fields[d.name] = d.value;
|
||||
}
|
||||
if (!id(fields.toolCallId) || !enums.stage.includes(fields.stage!) || origin.eventType !== fields.stage || origin.method !== (fields.stage === "tool" ? "session/update" : "session/request_permission")) throw new Error("Invalid Copilot evidence origin");
|
||||
const observedAtMs = Date.parse(envelope.emittedAt);
|
||||
if (!Number.isFinite(observedAtMs)) throw new Error("Copilot evidence has no producer timestamp");
|
||||
const notice: Record<string, unknown> = { runId: expectedRunId, sessionId: origin.sessionId, turnId: origin.turnId, toolCallId: fields.toolCallId, stage: fields.stage, observedAtMs, seq: r.seq };
|
||||
for (const [key, choices] of Object.entries(enums)) if (fields[key] !== undefined) { if (!(choices as readonly string[]).includes(fields[key]!)) throw new Error("Invalid Copilot evidence enum"); notice[key] = fields[key]; }
|
||||
for (const key of ["requestId", "commandToolCallId"]) if (fields[key] !== undefined) { if (!id(fields[key])) throw new Error("Invalid Copilot evidence identity"); notice[key] = fields[key]; }
|
||||
for (const key of ["detach", "declineOffered"]) if (fields[key] !== undefined) { if (!["true", "false"].includes(fields[key]!)) throw new Error("Invalid Copilot evidence boolean"); notice[key] = fields[key] === "true"; }
|
||||
if (fields.target !== undefined) {
|
||||
if (!fields.target || fields.target.startsWith("/") || /[\\:\u0000-\u001f\u007f]/u.test(fields.target) || fields.target.split("/").some(x => x === "." || x === ".." || !x)) throw new Error("Invalid Copilot evidence target");
|
||||
notice.target = fields.target;
|
||||
}
|
||||
if (fields.readTargetSha256 !== undefined) { if (!/^sha256:[a-f0-9]{64}$/u.test(fields.readTargetSha256)) throw new Error("Invalid read digest"); notice.readTargetSha256 = fields.readTargetSha256; }
|
||||
if (fields.commandSha256 !== undefined) { if (!/^sha256:[a-f0-9]{64}$/u.test(fields.commandSha256)) throw new Error("Invalid command digest"); notice.commandSha256 = fields.commandSha256; }
|
||||
if (fields.shellId !== undefined) { if (!/^[A-Za-z0-9_.-]{1,80}$/u.test(fields.shellId)) throw new Error("Invalid shell identity"); notice.shellId = fields.shellId; }
|
||||
if (fields.exitCode !== undefined) { if (!/^-?\d{1,10}$/u.test(fields.exitCode) || !Number.isSafeInteger(Number(fields.exitCode))) throw new Error("Invalid exit code"); notice.exitCode = Number(fields.exitCode); }
|
||||
const semanticKeys = ["semanticOperationId", "semanticCallIdentitySha256", "semanticInputSha256", "semanticNormalizedInputSha256", "semanticResultSha256", "semanticOutcome"];
|
||||
if (semanticKeys.some(k => fields[k] !== undefined)) {
|
||||
if (!semanticKeys.every(k => fields[k] !== undefined) || fields.stage !== "tool" || !["completed", "failed"].includes(fields.status!)
|
||||
|| !/^[A-Za-z0-9_.:-]{1,256}$/.test(fields.semanticOperationId!) || !["returned", "error"].includes(fields.semanticOutcome!)
|
||||
|| ["semanticCallIdentitySha256", "semanticInputSha256", "semanticResultSha256"].some(k => !/^[a-f0-9]{64}$/.test(fields[k]!))) throw new Error("Invalid semantic receipt fields");
|
||||
if (fields.semanticNormalizedInputSha256 !== "null" && !/^[a-f0-9]{64}$/.test(fields.semanticNormalizedInputSha256!)) throw new Error("Invalid normalized semantic digest");
|
||||
for (const key of semanticKeys) notice[key] = fields[key];
|
||||
notice.semanticNormalizedInputSha256 = fields.semanticNormalizedInputSha256 === "null" ? null : fields.semanticNormalizedInputSha256;
|
||||
}
|
||||
result.push(notice as unknown as CopilotToolNotice);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function copilotOrigin(n: CopilotToolNotice) {
|
||||
return { runId: n.runId, sessionId: n.sessionId, turnId: n.turnId, toolCallId: n.toolCallId };
|
||||
}
|
||||
|
||||
/** A patch notification can omit its path. Bind the pending native edit to the
|
||||
* exact permission carrying that path; never derive a path from display text or
|
||||
* borrow another operation's permission. Durable stream/card checks follow. */
|
||||
export function copilotEditOriginForPermission(notices: readonly CopilotToolNotice[], target: string, expectedToolCallId?: string): CopilotToolNotice | undefined {
|
||||
const requests = notices.filter(n => n.stage === "permission_requested" && n.operation === "edit" && n.target === target
|
||||
&& (!expectedToolCallId || n.toolCallId === expectedToolCallId));
|
||||
if (!requests.length) return undefined;
|
||||
if (requests.length !== 1) throw new Error("Ambiguous Copilot edit permission target");
|
||||
const request = requests[0]!;
|
||||
const group = notices.filter(n => n.toolCallId === request.toolCallId);
|
||||
if (group.some(n => n.runId !== request.runId || n.sessionId !== request.sessionId || n.turnId !== request.turnId
|
||||
|| (n.target !== undefined && n.target !== target) || (n.operation !== undefined && n.operation !== "edit"))) throw new Error("Conflicting Copilot edit permission origin");
|
||||
const pending = group.filter(n => n.stage === "tool" && n.status === "pending" && n.operation === "edit");
|
||||
if (pending.length > 1) throw new Error("Duplicate Copilot pending edit origin");
|
||||
return pending[0];
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { canonicalRemoteCopilotCommand, copilotDeathArguments, copilotDeathCommandDigest, selectOwnedCopilotProcess } from "./copilot-provider-death.js";
|
||||
import { parseRemoteProcStat } from "./remote-native-fixtures.js";
|
||||
import { spawn, execFileSync, type ChildProcess } from "node:child_process";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { watch, lstatSync, readFileSync, readlinkSync, statSync, type FSWatcher } from "node:fs";
|
||||
import { lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { createServer, type Socket } from "node:net";
|
||||
import { basename, join, relative } from "node:path";
|
||||
|
||||
export const sha256 = (value: string) => `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
||||
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||
export async function exists(path: string): Promise<boolean> {
|
||||
try { await lstat(path); return true; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
|
||||
}
|
||||
/** Allocate before dispatch; ordinary workspace startup cannot mutate this parent. */
|
||||
export async function createDeniedTargetFixture(workspacePath: string, name: string) {
|
||||
if (!name || basename(name) !== name || name === "." || name === "..") throw new Error("Invalid denied target name");
|
||||
const directory = await mkdtemp(join(workspacePath, "pc-denied-"));
|
||||
const targetPath = join(directory, name);
|
||||
return { directory, targetPath, targetRelativePath: relative(workspacePath, targetPath), watcher: watchDeniedTarget(directory, name) };
|
||||
}
|
||||
|
||||
/** Substitute the one authored target, never append a contradictory second path. */
|
||||
export function bindDeniedTargetPrompt(prompt: string, original: string, target: string): string {
|
||||
const parts = prompt.split(original);
|
||||
if (parts.length !== 2) throw new Error("Denied prompt must name its exact target once");
|
||||
return parts.join(target);
|
||||
}
|
||||
|
||||
export function watchDeniedTarget(directory: string, name: string) {
|
||||
if (!name || basename(name) !== name || name === "." || name === "..") throw new Error("Invalid denied target name");
|
||||
const startedAtMs = Date.now(); let targetMutationCount = 0;
|
||||
const reasons = new Set<string>();
|
||||
const before = lstatSync(directory, { bigint: true });
|
||||
if (!before.isDirectory() || before.isSymbolicLink()) throw new Error("Denied target parent must be a real directory");
|
||||
const targetAbsent = () => { try { lstatSync(join(directory, name)); return false; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return true; throw error; } };
|
||||
if (!targetAbsent()) throw new Error("Denied target must initially be absent");
|
||||
const identity = (stat: import("node:fs").BigIntStats) => ({ dev: String(stat.dev), ino: String(stat.ino), mtimeNs: String(stat.mtimeNs), ctimeNs: String(stat.ctimeNs) });
|
||||
const events: Array<{ sequence: number; observedAtMs: number; kind: string; target: boolean; filenameKnown: boolean }> = [];
|
||||
let eventCount = 0, lastEventAtMs = startedAtMs, closing = false;
|
||||
let final: { startedAtMs: number; endedAtMs: number; complete: boolean; targetMutationCount: number; reasons: string[]; initialParent: ReturnType<typeof identity>; finalParent: ReturnType<typeof identity> | null; events: typeof events } | undefined;
|
||||
const watcher: FSWatcher = watch(directory, (kind, filename) => {
|
||||
const observedAtMs = Date.now();
|
||||
if (observedAtMs < lastEventAtMs) reasons.add("event-order-invalid");
|
||||
lastEventAtMs = observedAtMs;
|
||||
const target = filename !== null && String(filename) === name;
|
||||
if (filename === null) reasons.add("event-filename-missing");
|
||||
if (target) targetMutationCount++;
|
||||
if (++eventCount <= 128) events.push({ sequence: eventCount, observedAtMs, kind, target, filenameKnown: filename !== null });
|
||||
else reasons.add("event-journal-overflow");
|
||||
});
|
||||
watcher.on("error", () => { reasons.add("watch-error"); });
|
||||
watcher.on("close", () => { if (!closing) reasons.add("watch-closed-before-finish"); });
|
||||
// Pin both identity and directory version across watcher installation.
|
||||
try {
|
||||
const armed = lstatSync(directory, { bigint: true });
|
||||
if (!armed.isDirectory() || armed.dev !== before.dev || armed.ino !== before.ino) reasons.add("parent-identity-changed-during-arm");
|
||||
if (armed.mtimeNs !== before.mtimeNs || armed.ctimeNs !== before.ctimeNs || !targetAbsent()) reasons.add("coverage-gap-during-arm");
|
||||
} catch { reasons.add("parent-unavailable-during-arm"); }
|
||||
return { finish() {
|
||||
if (final) return final;
|
||||
let after: import("node:fs").BigIntStats | undefined;
|
||||
try { after = lstatSync(directory, { bigint: true }); } catch { reasons.add("parent-unavailable-at-finish"); }
|
||||
// FSEvents may coalesce a rapid create/delete. A changed directory version
|
||||
// with no attributed event is a coverage gap, never proof of no mutation.
|
||||
if (after && (after.dev !== before.dev || after.ino !== before.ino || !after.isDirectory())) reasons.add("parent-identity-changed");
|
||||
if (after && (after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) && targetMutationCount === 0) reasons.add("coverage-gap-parent-version-changed");
|
||||
try { if (!targetAbsent() && targetMutationCount === 0) reasons.add("coverage-gap-unobserved-target"); } catch { reasons.add("target-unavailable-at-finish"); }
|
||||
const endedAtMs = Date.now();
|
||||
if (endedAtMs < lastEventAtMs) reasons.add("event-order-invalid");
|
||||
final = { startedAtMs, endedAtMs, complete: reasons.size === 0, targetMutationCount, reasons: [...reasons], initialParent: identity(before), finalParent: after ? identity(after) : null, events };
|
||||
closing = true; watcher.close(); return final;
|
||||
} };
|
||||
}
|
||||
interface ProcessIdentity { pid: number; parent: number; start: string; bootId?: string; startTicks?: string; state?: string }
|
||||
export function sameObservedProcess(left: ProcessIdentity | undefined, right: ProcessIdentity): boolean {
|
||||
if (!left || left.pid !== right.pid) return false;
|
||||
if (left.bootId !== undefined || right.bootId !== undefined) return Boolean(left.bootId && left.bootId === right.bootId && left.startTicks && left.startTicks === right.startTicks);
|
||||
return left.start === right.start;
|
||||
}
|
||||
function processTable(): ProcessIdentity[] {
|
||||
const output = execFileSync("/bin/ps", ["-axo", "pid=,ppid=,lstart="], { encoding: "utf8", timeout: 3000, maxBuffer: 8 * 1024 * 1024, env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } });
|
||||
const bootId = process.platform === "linux" ? readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim() : undefined;
|
||||
return output.split("\n").flatMap(line => {
|
||||
const m = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/u.exec(line);
|
||||
if (!m) return [];
|
||||
const pid = Number(m[1]);
|
||||
if (pid < 2) return [];
|
||||
try {
|
||||
// Match hot-restart.readProcessStartedAt, the server's Linux identity
|
||||
// source. ps lstart is rounded and does not identify the same timestamp.
|
||||
const start = process.platform === "linux" ? new Date(lstatSync(`/proc/${pid}`).ctimeMs).toISOString() : m[3]!;
|
||||
if (bootId) {
|
||||
const identity = parseRemoteProcStat(pid, readFileSync(`/proc/${pid}/stat`, "utf8"), bootId);
|
||||
if (identity.state === "Z") return [];
|
||||
return [{ pid, parent: identity.ppid, start, bootId, startTicks: identity.startTicks, state: identity.state }];
|
||||
}
|
||||
return [{ pid, parent: Number(m[2]), start }];
|
||||
} catch (error) {
|
||||
if (["ENOENT", "ESRCH"].includes((error as NodeJS.ErrnoException).code ?? "")) return [];
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
export interface RunProcessAuthority { pid: number; groupId: number; startedAt: string; runId: string }
|
||||
export function isPerTurnRunProcess(authority: RunProcessAuthority, observed: ProcessIdentity, command: string): boolean {
|
||||
if (!/^[a-zA-Z0-9_-]{1,128}$/u.test(authority.runId) || authority.pid !== observed.pid || authority.groupId !== observed.pid) return false;
|
||||
const started = Date.parse(authority.startedAt), actual = Date.parse(observed.start);
|
||||
if (!Number.isFinite(started) || !Number.isFinite(actual) || started !== actual) return false;
|
||||
const args = command.trim().split(/\s+/u);
|
||||
return [["--run-id", authority.runId], ["--lifecycle-mode", "per_turn"]].every(([flag, value]) => {
|
||||
const at = args.indexOf(flag!); return at >= 0 && args.lastIndexOf(flag!) === at && args[at + 1] === value;
|
||||
});
|
||||
}
|
||||
/** Inspect the actual executable and its held descriptor before accepting the
|
||||
* Linux native launcher alias. Never canonicalize an unverified command name. */
|
||||
function ownedProcessCommand(pid: number): readonly string[] {
|
||||
const command = execFileSync("/bin/ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8", timeout: 3000, maxBuffer: 64 * 1024 }).trim().split(/\s+/u);
|
||||
if (process.platform !== "linux" || !/^\/proc\/self\/fd\/(?:3|7)$/u.test(command[0] ?? "")) return command;
|
||||
const executable = `/proc/${pid}/exe`, descriptor = `/proc/${pid}/fd/${command[0]!.split("/").at(-1)}`;
|
||||
const executableStat = statSync(executable, { bigint: true }), descriptorStat = statSync(descriptor, { bigint: true });
|
||||
return canonicalRemoteCopilotCommand(command, readlinkSync(executable),
|
||||
{ dev: String(executableStat.dev), ino: String(executableStat.ino) },
|
||||
{ path: readlinkSync(descriptor), dev: String(descriptorStat.dev), ino: String(descriptorStat.ino) });
|
||||
}
|
||||
/** Read-only PID/start journal; never signal an API-reported or reused PID.
|
||||
* Native onSpawn publishes the runnerd child. Bind its current argv to this run
|
||||
* and per_turn lifecycle before treating its retirement as run cleanup.
|
||||
*/
|
||||
export function observeRunProcesses() {
|
||||
const owned = new Map<number, ProcessIdentity>(); let root: ProcessIdentity | undefined; let providerDeathDispatched = false;
|
||||
let authorityCheck: { authority: RunProcessAuthority; observed: ProcessIdentity; commandSha256: string; accepted: boolean } | undefined;
|
||||
return {
|
||||
killOwnedCopilot() {
|
||||
if (!root || providerDeathDispatched) throw new Error("Copilot death requires a captured run and one dispatch");
|
||||
const table = processTable();
|
||||
const live = table.filter(p => sameObservedProcess(owned.get(p.pid), p)).map(p => p.pid);
|
||||
const argv = new Map(live.map(pid => [pid, ownedProcessCommand(pid)]));
|
||||
const child = selectOwnedCopilotProcess(root.pid, [...owned.values()], live, argv, copilotDeathArguments);
|
||||
const fresh = processTable();
|
||||
if (!fresh.some(p => sameObservedProcess(root, p)) || !fresh.some(p => sameObservedProcess(child, p))) throw new Error("Copilot process identity changed before death dispatch");
|
||||
const command = ownedProcessCommand(child.pid);
|
||||
if (JSON.stringify(command) !== JSON.stringify(argv.get(child.pid))) throw new Error("Copilot command changed before death dispatch");
|
||||
providerDeathDispatched = true;
|
||||
const dispatchedMonotonicNs = process.hrtime.bigint().toString();
|
||||
process.kill(child.pid, "SIGKILL");
|
||||
return { schema: "paperclip.e2e.copilot-owned-provider-death.v1", root, child, signal: "SIGKILL", commandSha256: copilotDeathCommandDigest(command), dispatchedMonotonicNs };
|
||||
},
|
||||
sample(authority?: RunProcessAuthority) {
|
||||
const table = processTable();
|
||||
if (!root && authority && authority.pid > 1 && authority.pid !== process.pid) {
|
||||
const candidate = table.find(p => p.pid === authority.pid);
|
||||
if (candidate) {
|
||||
let command: string;
|
||||
try { command = execFileSync("/bin/ps", ["-p", String(candidate.pid), "-o", "command="], { encoding: "utf8", timeout: 3000, maxBuffer: 64 * 1024 }); }
|
||||
catch { command = ""; } // A vanished/uninspectable PID supplies no authority.
|
||||
const accepted = isPerTurnRunProcess(authority, candidate, command) && processTable().some(p => sameObservedProcess(candidate, p));
|
||||
authorityCheck = { authority, observed: candidate, commandSha256: sha256(command), accepted };
|
||||
if (accepted) { root = candidate; owned.set(root.pid, root); }
|
||||
}
|
||||
}
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const p of table) if (!owned.has(p.pid)) {
|
||||
const parent = owned.get(p.parent);
|
||||
if (parent && table.some(t => sameObservedProcess(parent, t))) { owned.set(p.pid, p); changed = true; }
|
||||
}
|
||||
}
|
||||
return { captured: Boolean(root), authorityCheck, rootObserved: root ? table.find(p => p.pid === root!.pid) : undefined,
|
||||
identityChanged: table.some(p => owned.has(p.pid) && !sameObservedProcess(owned.get(p.pid), p)),
|
||||
journal: [...owned.values()], live: table.filter(p => sameObservedProcess(owned.get(p.pid), p)).map(p => p.pid) };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A one-shot local fixture, not a command service: input selects no executable,
|
||||
* arguments or file path. The test owns/reaps the fixed finite child, records its
|
||||
* OS exit status, and holds the exact provider-launched client until that exit.
|
||||
*/
|
||||
export async function createAttachedCommandFixture(markerPath: string, delayMs = 4000) {
|
||||
if (!Number.isInteger(delayMs) || delayMs < 100 || delayMs > 8000) throw new Error("Invalid bounded fixture delay");
|
||||
const root = await mkdtemp("/tmp/pc-copilot-"); await mkdir(join(root, "private"), { mode: 0o700 });
|
||||
const socketPath = join(root, "private", "socket"), scriptPath = join(root, "client.cjs");
|
||||
const nonce = randomBytes(16).toString("hex"), marker = `${randomBytes(24).toString("hex")}\n`;
|
||||
const script = `const net=require('node:net');const s=net.connect(process.argv[2]);let b='';s.setTimeout(15000,()=>process.exit(3));s.on('error',()=>process.exit(4));s.on('connect',()=>s.write(JSON.stringify({nonce:process.argv[3],pid:process.pid})+'\\n'));s.on('data',x=>{b+=x;if(b.includes('\\n')){const r=JSON.parse(b);s.end();process.exit(r.code===0?0:5);}});`;
|
||||
await writeFile(scriptPath, script, { mode: 0o400 });
|
||||
const command = `${quote(process.execPath)} ${quote(scriptPath)} ${quote(socketPath)} ${quote(nonce)}`;
|
||||
const commandSha256 = sha256(command);
|
||||
let markerWrittenAtMs: number | null = null;
|
||||
let clientExitedAtMs: number | null = null;
|
||||
let clientObservation: ReturnType<typeof setInterval> | undefined;
|
||||
let connections = 0, child: ChildProcess | undefined, client: ProcessIdentity | undefined, failure: string | null = null;
|
||||
let exit: { observedAtMs: number; code: number; ownedProcessIdentityVerified: boolean; commandSha256: string } | null = null;
|
||||
const sockets = new Set<Socket>();
|
||||
let closed = false;
|
||||
const server = createServer(socket => {
|
||||
sockets.add(socket); socket.on("close", () => sockets.delete(socket)); socket.on("error", () => { failure = "fixture_socket_error"; });
|
||||
let buffer = "";
|
||||
socket.on("data", data => {
|
||||
buffer += data; if (buffer.length > 1024) { failure = "fixture_request_too_large"; socket.destroy(); return; }
|
||||
if (!buffer.includes("\n")) return;
|
||||
connections++;
|
||||
try {
|
||||
const request = JSON.parse(buffer); buffer = "";
|
||||
if (connections !== 1 || request.nonce !== nonce || !Number.isSafeInteger(request.pid) || request.pid <= 1) throw new Error("fixture_request_invalid");
|
||||
const observed = processTable().find(p => p.pid === request.pid);
|
||||
const argv = execFileSync("/bin/ps", ["-p", String(request.pid), "-o", "command="], { encoding: "utf8", timeout: 3000 });
|
||||
if (!observed || !argv.includes(scriptPath) || !argv.includes(socketPath) || !argv.includes(nonce)) throw new Error("fixture_client_identity_invalid");
|
||||
client = observed;
|
||||
clientObservation = setInterval(() => {
|
||||
try {
|
||||
if (!processTable().some(p => sameObservedProcess(client, p))) {
|
||||
clientExitedAtMs = Date.now(); clearInterval(clientObservation);
|
||||
}
|
||||
} catch { failure = "fixture_client_observation_failed"; clearInterval(clientObservation); }
|
||||
}, 25);
|
||||
child = spawn(process.execPath, ["-e", `setTimeout(()=>process.exit(0),${delayMs})`], { env: { PATH: "/usr/bin:/bin" }, stdio: "ignore" });
|
||||
child.once("error", () => { failure = "fixture_child_start_failed"; socket.destroy(); });
|
||||
child.once("exit", (code, signal) => {
|
||||
exit = { observedAtMs: Date.now(), code: code ?? -1, ownedProcessIdentityVerified: Number.isInteger(child?.pid), commandSha256 };
|
||||
void (async () => {
|
||||
if (code !== 0 || signal) { failure = "fixture_child_failed"; socket.destroy(); return; }
|
||||
if (await readFile(scriptPath, "utf8") !== script) { failure = "fixture_script_changed"; socket.destroy(); return; }
|
||||
await writeFile(markerPath, marker, { flag: "wx" }); markerWrittenAtMs = Date.now(); socket.end(`${JSON.stringify({ code })}\n`);
|
||||
})().catch(() => { failure = "fixture_completion_failed"; socket.destroy(); });
|
||||
});
|
||||
} catch { failure = "fixture_request_rejected"; socket.destroy(); }
|
||||
});
|
||||
});
|
||||
try { await new Promise<void>((resolve, reject) => { server.once("error", reject); server.listen(socketPath, resolve); }); }
|
||||
catch (error) { server.close(); await rm(root, { recursive: true, force: true }); throw error; }
|
||||
return {
|
||||
command, commandSha256, marker,
|
||||
snapshot() { const table = processTable(); return { connections, failure, markerWrittenAtMs, clientExitedAtMs, commandExit: exit, childPid: child?.pid ?? null, clientPid: client?.pid ?? null, clientGone: Boolean(client) && !table.some(p => sameObservedProcess(client, p)), childGone: Boolean(exit), observedAtMs: Date.now() }; },
|
||||
async close() {
|
||||
if (closed) return; closed = true; clearInterval(clientObservation);
|
||||
let cleanupError: unknown;
|
||||
try {
|
||||
if (child && child.exitCode === null && child.signalCode === null) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const hard = setTimeout(() => { child!.kill("SIGKILL"); }, 3000);
|
||||
const deadline = setTimeout(() => reject(new Error("Fixture child did not settle within cleanup bound")), 5000);
|
||||
child!.once("exit", () => { clearTimeout(hard); clearTimeout(deadline); resolve(); }); child!.kill("SIGTERM");
|
||||
});
|
||||
}
|
||||
} catch (error) { cleanupError = error; }
|
||||
finally {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
try { await new Promise<void>(resolve => server.close(() => resolve())); }
|
||||
finally { await rm(root, { recursive: true, force: true }); }
|
||||
}
|
||||
if (cleanupError) throw cleanupError;
|
||||
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { lstatSync } from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isPerTurnRunProcess, observeRunProcesses, sameObservedProcess } from "./copilot-local-fixtures.js";
|
||||
|
||||
describe("Copilot local process ownership", () => {
|
||||
it("retains the kernel birth identity across proc-directory timestamp changes", () => {
|
||||
const original = { pid: 100, parent: 1, start: "2026-10-08T01:18:30.820Z", bootId: "00000000-0000-0000-0000-000000000001", startTicks: "1000" };
|
||||
expect(sameObservedProcess(original, { ...original, start: "2026-10-08T01:18:32.000Z" })).toBe(true);
|
||||
for (const changed of [{ pid: 101 }, { startTicks: "1001" }, { bootId: "00000000-0000-0000-0000-000000000002" }, { startTicks: undefined }, { bootId: undefined }]) {
|
||||
expect(sameObservedProcess(original, { ...original, ...changed })).toBe(false);
|
||||
}
|
||||
expect(sameObservedProcess(undefined, original)).toBe(false);
|
||||
});
|
||||
it.skipIf(process.platform !== "linux")("captures the exact Linux process start identity published by the server", async () => {
|
||||
const runId = randomUUID();
|
||||
const child = spawn(process.execPath, ["-e", "setInterval(()=>{},1000)", "--", "--run-id", runId, "--lifecycle-mode", "per_turn"], { detached: true, stdio: "ignore", env: { PATH: "/usr/bin:/bin" } });
|
||||
const exited = new Promise<void>(resolve => child.once("exit", () => resolve()));
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
|
||||
const pid = child.pid!;
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
// hot-restart.readProcessStartedAt publishes /proc/<pid> ctime on Linux,
|
||||
// which has different semantics and precision from ps's birth time.
|
||||
const startedAt = new Date(lstatSync(`/proc/${pid}`).ctimeMs).toISOString();
|
||||
const observer = observeRunProcesses();
|
||||
const authority = { pid, groupId: pid, startedAt, runId };
|
||||
const captured = observer.sample(authority);
|
||||
expect(captured.captured).toBe(true);
|
||||
expect(captured.live).toContain(pid);
|
||||
expect(observeRunProcesses().sample({ ...authority, startedAt: new Date(Date.parse(startedAt) + 1).toISOString() }).captured).toBe(false);
|
||||
child.kill("SIGTERM"); await exited;
|
||||
expect(observer.sample(authority).live).toEqual([]);
|
||||
} finally {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
await exited;
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a different millisecond in the Linux authority instead of rounding it to seconds", () => {
|
||||
const start = "2026-10-08T01:02:58.051Z";
|
||||
const authority = { pid: 100, groupId: 100, startedAt: start, runId: "owned-run" };
|
||||
const observed = { pid: 100, parent: 1, start };
|
||||
const command = "/owned/runner --run-id owned-run --lifecycle-mode per_turn";
|
||||
expect(isPerTurnRunProcess(authority, observed, command)).toBe(true);
|
||||
expect(isPerTurnRunProcess({ ...authority, startedAt: "2026-10-08T01:02:58.052Z" }, observed, command)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { copilotPendingPermissionCard } from "./copilot-permission-card.js";
|
||||
|
||||
test("selects the unanswered exact target while completed context remains visible", async ({ page }) => {
|
||||
await page.setContent('<div data-testid="task-chat-runtime-request">get_task_context — resolved</div><div data-testid="task-chat-runtime-request">Change file: pc-denied-A/target.txt<button>Deny</button><button>Allow once</button></div>');
|
||||
await expect(page.getByTestId("task-chat-runtime-request")).toHaveCount(2);
|
||||
const card = copilotPendingPermissionCard(page, "pc-denied-A/target.txt");
|
||||
await expect(card).toHaveCount(1);
|
||||
await expect(card.getByRole("button", { name: "Deny", exact: true })).toBeEnabled();
|
||||
await expect(copilotPendingPermissionCard(page, "get_task_context")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("preserves duplicate detection and never borrows another target's control", async ({ page }) => {
|
||||
await page.setContent('<div data-testid="task-chat-runtime-request">Change file: expected.txt — resolved</div><div data-testid="task-chat-runtime-request">Change file: foreign.txt<button>Deny</button></div>');
|
||||
await expect(copilotPendingPermissionCard(page, "expected.txt")).toHaveCount(0);
|
||||
await page.setContent('<div data-testid="task-chat-runtime-request">Change file: expected.txt<button>Deny</button></div><div data-testid="task-chat-runtime-request">Change file: expected.txt<button>Deny</button></div>');
|
||||
await expect(copilotPendingPermissionCard(page, "expected.txt")).toHaveCount(2);
|
||||
});
|
||||
|
||||
test("selects a new context decision without reusing an answered discovery card", async ({ page }) => {
|
||||
await page.setContent('<div data-testid="task-chat-runtime-request">search_api: get_task_context — resolved</div><div data-testid="task-chat-runtime-request">get_task_context<button>Deny</button><button>Allow once</button></div>');
|
||||
await expect(copilotPendingPermissionCard(page, "get_task_context")).toHaveCount(1);
|
||||
await expect(copilotPendingPermissionCard(page, "search_api")).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
/** Answered context/discovery cards remain visible history. Match the current
|
||||
* operation and its decision control; API assertions independently require one
|
||||
* exact pending request and every POST is bound to its run/request/turn. */
|
||||
export function copilotPendingPermissionCard(page: Page, operationOrTarget: string) {
|
||||
return page.getByTestId("task-chat-runtime-request")
|
||||
.filter({ visible: true, hasText: operationOrTarget })
|
||||
.filter({ has: page.getByRole("button", { name: "Deny", exact: true }) });
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { copilotEditOriginForPermission, readCopilotToolEvidence } from "./copilot-evidence.js";
|
||||
import { observeActiveStopPending, readActiveStopCaller } from "./copilot-active-stop-evidence.js";
|
||||
const retained = JSON.parse(readFileSync(new URL("./fixtures/copilot-patch-permission-target-v15.json", import.meta.url), "utf8"));
|
||||
const caller = readActiveStopCaller({ deploymentMode: "local_trusted" }, { session: { userId: "local-board", id: "paperclip:local_implicit:local-board" } });
|
||||
function fixture() {
|
||||
const { rows: events, scope, run, issue } = structuredClone(retained);
|
||||
const notices = readCopilotToolEvidence(events, run.id);
|
||||
const read = () => observeActiveStopPending({ events, scope, run, issue, caller, cancellationRequestId: "11111111-2222-4333-8444-555555555555" });
|
||||
return { events, scope, notices, read };
|
||||
}
|
||||
describe("pending patch target bound by its exact permission", () => {
|
||||
it("calibrates the retained missing-notification path while keeping the actual card target", () => {
|
||||
const f = fixture(), origin = copilotEditOriginForPermission(f.notices, f.scope.target)!;
|
||||
expect(origin.target).toBeUndefined();
|
||||
expect(origin.toolCallId).toBe("custom_call_jyQm34Kq61JjSxkZUXvPSNrh");
|
||||
expect(f.read()).toMatchObject({ toolCallId: origin.toolCallId, scope: { target: f.scope.target }, requestId: "acpx-input-ccaa35c8d2130be28a27e1d3" });
|
||||
expect(retained.outcome).toContain("calibration only");
|
||||
});
|
||||
it.each(["wrong-call", "foreign-session", "foreign-turn", "foreign-run", "conflicting-path", "conflicting-operation", "duplicate-pending", "duplicate-permission", "missing-permission"])("refuses %s", kind => {
|
||||
const f = fixture();
|
||||
const origin = copilotEditOriginForPermission(f.notices, f.scope.target)!;
|
||||
const permission = f.notices.find(n => n.stage === "permission_requested" && n.toolCallId === origin.toolCallId)!;
|
||||
if (kind === "wrong-call") permission.toolCallId = "unrelated";
|
||||
if (kind === "foreign-session") permission.sessionId = "foreign";
|
||||
if (kind === "foreign-turn") permission.turnId = "foreign";
|
||||
if (kind === "foreign-run") permission.runId = "foreign";
|
||||
if (kind === "conflicting-path") origin.target = "another.txt";
|
||||
if (kind === "conflicting-operation") origin.operation = "execute";
|
||||
if (kind === "duplicate-pending") f.notices.push({ ...origin, seq: origin.seq + 1 });
|
||||
if (kind === "duplicate-permission") f.notices.push({ ...permission, seq: permission.seq + 1 });
|
||||
if (kind === "missing-permission") f.notices.splice(f.notices.indexOf(permission), 1);
|
||||
if (["wrong-call", "missing-permission"].includes(kind)) expect(copilotEditOriginForPermission(f.notices, f.scope.target)).toBeUndefined();
|
||||
else expect(() => copilotEditOriginForPermission(f.notices, f.scope.target)).toThrow(/Copilot.*(?:origin|target)/);
|
||||
});
|
||||
it("cannot borrow a target from another native operation", () => {
|
||||
const f = fixture();
|
||||
expect(copilotEditOriginForPermission(f.notices, f.scope.target, "other-call")).toBeUndefined();
|
||||
});
|
||||
it.each(["card-call", "card-request", "card-source", "wrong-provider", "wrong-adapter", "wrong-method", "permission-path", "context-after-edit"])("keeps durable evidence guard for %s", kind => {
|
||||
const f = fixture();
|
||||
const row = (eventType: string, seq: number) => f.events.find((r: any) => r.eventType === eventType && r.seq === seq);
|
||||
const payload = (r: any) => r.payload.prpEvent.payload;
|
||||
if (kind === "card-call") payload(row("runtime_request.created", 134)).request.details.toolCallId = "other-call";
|
||||
if (kind === "card-request") payload(row("runtime_request.created", 134)).request.requestId = "other-request";
|
||||
if (kind === "wrong-provider") payload(row("runtime_request.created", 134)).request.origin.provider = "cursor";
|
||||
if (kind === "wrong-adapter") payload(row("runtime_request.created", 134)).request.origin.adapter = "untrusted";
|
||||
if (kind === "wrong-method") payload(row("runtime_request.created", 134)).request.origin.method = "other";
|
||||
if (kind === "card-source") row("runtime_request.created", 134).payload.prpEvent.sourceInstanceId = "other-source";
|
||||
if (kind === "permission-path") payload(row("provider.notice.recorded", 137)).details.find((d: any) => d.name === "target").value = "other.txt";
|
||||
if (kind === "context-after-edit") row("tool.execution.completed", 130).seq = 136;
|
||||
expect(f.read).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { matchCopilotFixtureCommand } from "./copilot-protection-evidence.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { copilotProtectionCases, gradeCopilotAttachedSettlement, gradeCopilotDeniedWrite, type CopilotAttachedSettlementEvidence, type CopilotDeniedWriteEvidence } from "./copilot-protection-cases.js";
|
||||
const identity = { runId: "run", sessionId: "session", turnId: "turn", toolCallId: "tool" };
|
||||
const terminal = { observedAtMs: 50, runId: "run", turnId: "turn", status: "succeeded" as const };
|
||||
const cleanup = { observedAtMs: 60, ownedProcessesRemaining: 0 };
|
||||
function denied(): CopilotDeniedWriteEvidence {
|
||||
return {
|
||||
expected: identity, terminal: { ...terminal, status: "cancelled" }, cleanup, settlement: { schema: "paperclip.e2e.copilot-denial-settlement.v4", ...identity, requestId: "permission-0",
|
||||
branch: "provider_cancelled_or_interrupted", providerCancellationTerminalObserved: true,
|
||||
preStop: { schema: "paperclip.e2e.copilot-pre-stop-observation.v2" as const, ...identity, requestId: "permission-0", companyId: "company", normalizedSessionId: "normalized", sourceInstanceId: "runner", failedToolSourceSeq: 5, failedToolRowSha256: `sha256:${"a".repeat(64)}`, terminal: null, apiReadCompletedMonotonicNs: "10", cancellationRequestId: "11111111-1111-4111-8111-111111111111" }, stopDispatchMonotonicNs: "20",
|
||||
stopAcknowledgement: { schema: "paperclip.e2e.copilot-stop-acknowledgement.v1" as const, companyId: "company", runId: "run", issueId: "issue", callerUserId: "local-board", fixtureCorrelationId: "11111111-1111-4111-8111-111111111111", dispatchMonotonicNs: "20", apiResponseObservedMonotonicNs: "21", intentId: "native-cancellation:11111111-1111-4111-8111-111111111111", intentAuditId: "intent-audit", acknowledgementAuditId: "ack-audit", responseMetadataSha256: `sha256:${"c".repeat(64)}` },
|
||||
providerTerminal: { rowSha256: `sha256:${"b".repeat(64)}`, failedToolRowSha256: `sha256:${"a".repeat(64)}`, eventType: "turn.cancelled", normalizedSessionId: "normalized", sourceInstanceId: "runner", requestSourceSeq: 1, resolvedSourceSeq: 2, deliveredSourceSeq: 3, failedNoticeSourceSeq: 4, failedToolSourceSeq: 5, failedToolRowCreatedAtMs: 31, sourceSeq: 6, emittedAtMs: 50, rowCreatedAtMs: 51 },
|
||||
runStop: { companyId: "company", issueId: "issue", scope: "run", status: "cancelled", issueStatus: "in_progress", intentId: "native-cancellation:11111111-1111-4111-8111-111111111111", intentAuditId: "intent-audit", acknowledgementAuditId: "ack-audit", requestedAtMs: 40, recordedAtMs: 41, acknowledgedAtMs: 52, finishedAtMs: 53 } }, requestId: "permission-0", expectedRelativePath: "copilot-denied-nonce.txt",
|
||||
request: { ...identity, observedAtMs: 10, method: "session/request_permission", requestId: "permission-0", targetRelativePath: "copilot-denied-nonce.txt", offeredActions: ["accept", "decline"] },
|
||||
decision: { ...identity, observedAtMs: 20, requestId: "permission-0", browserRequestId: "permission-0", action: "decline" },
|
||||
deliveredDecision: { ...identity, observedAtMs: 25, requestId: "permission-0", outcome: "reject_once" },
|
||||
toolResult: { ...identity, observedAtMs: 30, status: "failed" }, nativeAttemptsForTarget: 1,
|
||||
fileObservations: [
|
||||
{ phase: "before-request", observedAtMs: 0, exists: false, providerCursor: null }, { phase: "pending", observedAtMs: 15, exists: false, providerCursor: { runId: "run", turnId: "turn", normalizedSessionId: "normalized", sourceInstanceId: "runner", sourceSeq: 1 } },
|
||||
{ phase: "after-decision", observedAtMs: 25, exists: false, providerCursor: { runId: "run", turnId: "turn", normalizedSessionId: "normalized", sourceInstanceId: "runner", sourceSeq: 4 } }, { phase: "terminal", observedAtMs: 50, exists: false, providerCursor: { runId: "run", turnId: "turn", normalizedSessionId: "normalized", sourceInstanceId: "runner", sourceSeq: 6 } },
|
||||
{ phase: "after-cleanup", observedAtMs: 60, exists: false, providerCursor: { runId: "run", turnId: "turn", normalizedSessionId: "normalized", sourceInstanceId: "runner", sourceSeq: 6 } },
|
||||
],
|
||||
mutationObservation: { startedAtMs: 0, endedAtMs: 60, complete: true, targetMutationCount: 0 },
|
||||
};
|
||||
}
|
||||
function attached(): CopilotAttachedSettlementEvidence {
|
||||
const command = "'/pinned/node' '/fixture/client.cjs' '/fixture/private/socket' 'nonce'";
|
||||
const digest = `sha256:${createHash("sha256").update(command).digest("hex")}`;
|
||||
return {
|
||||
expected: identity, terminal: { ...terminal }, cleanup: { ...cleanup }, expectedCommand: command, expectedCommandSha256: digest, commandMatch: matchCopilotFixtureCommand(command, digest), expectedShellId: "0",
|
||||
nativeCall: { ...identity, observedAtMs: 10, operation: "execute", mode: "async", detach: false, commandSha256: digest },
|
||||
commandExit: { observedAtMs: 30, code: 0, ownedProcessIdentityVerified: true, commandSha256: digest },
|
||||
nativeShellResult: { ...identity, toolCallId: "read-shell-tool", commandToolCallId: "tool", observedAtMs: 40, shellId: "0", status: "completed", exitCode: 0 },
|
||||
terminalMarkerMatches: true, afterCleanupMarkerMatches: true,
|
||||
};
|
||||
}
|
||||
describe("Copilot protection Product oracles", () => {
|
||||
it("declares one bounded run and explicit discovery integration", () => {
|
||||
expect(copilotProtectionCases.map(c => c.id)).toEqual(["native-permission-deny-write", "attached-async-settlement"]);
|
||||
for (const c of copilotProtectionCases) { expect(c.expectedRunCount).toBe(1); expect(c.providerTimeoutSec).toBe(120); expect(c.integration).toBe("registered-copilot-protection-flow"); }
|
||||
expect(copilotProtectionCases[1].prompt("nonce")).toContain("detach false");
|
||||
});
|
||||
it("accepts an origin-bound browser denial with an independent continuous absence oracle", () => {
|
||||
expect(gradeCopilotDeniedWrite(denied())).toEqual({ passed: true, failures: [] });
|
||||
});
|
||||
it("keeps every no-effect and cleanup gate for a completed-before-Stop settlement", () => {
|
||||
const e = denied();
|
||||
e.settlement!.branch = "provider_completed_observed_before_stop";
|
||||
e.settlement!.providerCancellationTerminalObserved = false;
|
||||
e.settlement!.providerTerminal.eventType = "turn.completed";
|
||||
e.settlement!.preStop.terminal = { eventType: "turn.completed", sourceSeq: 6, rowSha256: e.settlement!.providerTerminal.rowSha256 };
|
||||
expect(gradeCopilotDeniedWrite(e).passed).toBe(true);
|
||||
for (const mutate of [
|
||||
(v: CopilotDeniedWriteEvidence) => { v.cleanup!.ownedProcessesRemaining = 1; },
|
||||
(v: CopilotDeniedWriteEvidence) => { v.nativeAttemptsForTarget = 2; },
|
||||
(v: CopilotDeniedWriteEvidence) => { v.fileObservations[2]!.exists = true; },
|
||||
(v: CopilotDeniedWriteEvidence) => { v.mutationObservation!.targetMutationCount = 1; },
|
||||
(v: CopilotDeniedWriteEvidence) => { v.mutationObservation!.complete = false; },
|
||||
(v: CopilotDeniedWriteEvidence) => { v.deliveredDecision = null; },
|
||||
(v: CopilotDeniedWriteEvidence) => { v.toolResult!.status = "completed"; },
|
||||
(v: CopilotDeniedWriteEvidence) => { v.settlement!.providerCancellationTerminalObserved = true; },
|
||||
(v: CopilotDeniedWriteEvidence) => { v.settlement!.turnId = "foreign"; },
|
||||
]) { const bad = structuredClone(e); mutate(bad); expect(gradeCopilotDeniedWrite(bad).passed).toBe(false); }
|
||||
});
|
||||
it.each([-86_400_000, 86_400_000])("completed settlement does not compare provider, browser and filesystem clocks (%s)", skew => {
|
||||
const e = denied();
|
||||
e.settlement!.branch = "provider_completed_observed_before_stop";
|
||||
e.settlement!.providerCancellationTerminalObserved = false;
|
||||
e.settlement!.providerTerminal.eventType = "turn.completed";
|
||||
e.settlement!.preStop.terminal = { eventType: "turn.completed", sourceSeq: 6, rowSha256: e.settlement!.providerTerminal.rowSha256 };
|
||||
// Distinct positive clock domains, with either provider ahead or behind.
|
||||
for (const n of [e.request!, e.deliveredDecision!, e.toolResult!, e.terminal!]) n.observedAtMs += 100_000_000 + skew;
|
||||
e.settlement!.providerTerminal.emittedAtMs = e.terminal!.observedAtMs;
|
||||
e.decision!.observedAtMs = 200_000_000;
|
||||
expect(gradeCopilotDeniedWrite(e).passed).toBe(true);
|
||||
e.fileObservations[1]!.providerCursor!.sourceSeq = e.settlement!.providerTerminal.resolvedSourceSeq;
|
||||
expect(gradeCopilotDeniedWrite(e).failures).toContain("filesystem-observation-order-invalid");
|
||||
});
|
||||
it.each(["runId", "turnId", "normalizedSessionId", "sourceInstanceId"] as const)("rejects foreign sample %s despite plausible timestamps", field => {
|
||||
const e = denied(); e.fileObservations[3]!.providerCursor![field] = "foreign";
|
||||
expect(gradeCopilotDeniedWrite(e).passed).toBe(false);
|
||||
});
|
||||
it("rejects missing, regressed and premature causal sample cursors", () => {
|
||||
for (const mutate of [
|
||||
(e: CopilotDeniedWriteEvidence) => { e.fileObservations[2]!.providerCursor = null; },
|
||||
(e: CopilotDeniedWriteEvidence) => { e.fileObservations[2]!.providerCursor!.sourceSeq = 3; },
|
||||
(e: CopilotDeniedWriteEvidence) => { e.fileObservations[3]!.providerCursor!.sourceSeq = 5; },
|
||||
(e: CopilotDeniedWriteEvidence) => { e.fileObservations[4]!.providerCursor!.sourceSeq = 5; },
|
||||
]) { const e = denied(); mutate(e); expect(gradeCopilotDeniedWrite(e).passed).toBe(false); }
|
||||
});
|
||||
it("requires the exact isolated target in native permission evidence", () => {
|
||||
const e = denied(); e.expectedRelativePath = "pc-denied-ABC123/copilot-denied-nonce.txt";
|
||||
expect(gradeCopilotDeniedWrite(e).passed).toBe(false);
|
||||
e.request!.targetRelativePath = e.expectedRelativePath;
|
||||
expect(gradeCopilotDeniedWrite(e).passed).toBe(true);
|
||||
for (const path of ["../copilot-denied-nonce.txt", "other/copilot-denied-nonce.txt", "pc-denied-ABC123/../copilot-denied-nonce.txt"]) {
|
||||
e.expectedRelativePath = path; e.request!.targetRelativePath = path;
|
||||
expect(gradeCopilotDeniedWrite(e).passed).toBe(false);
|
||||
}
|
||||
});
|
||||
it.each(["request", "decision", "deliveredDecision", "toolResult", "terminal", "cleanup", "mutationObservation", "settlement"] as const)("rejects missing %s instead of treating no write as denial", field => {
|
||||
const e = denied(); e[field] = null; expect(gradeCopilotDeniedWrite(e).passed).toBe(false);
|
||||
});
|
||||
it("requires explicit acknowledged cancellation for denial without relaxing successful settlement", () => {
|
||||
const e = denied(); e.settlement = null; expect(gradeCopilotDeniedWrite(e).passed).toBe(false);
|
||||
const finished = denied(); finished.terminal!.status = "succeeded"; expect(gradeCopilotDeniedWrite(finished).passed).toBe(false);
|
||||
const cancelled = attached(); cancelled.terminal!.status = "cancelled"; expect(gradeCopilotAttachedSettlement(cancelled).passed).toBe(false);
|
||||
});
|
||||
it("rejects a transient create/delete even when all five stat samples are absent", () => {
|
||||
const e = denied(); e.mutationObservation!.targetMutationCount = 2; expect(gradeCopilotDeniedWrite(e).failures).toContain("missing-or-mutated-filesystem-watch");
|
||||
});
|
||||
it("rejects missing watch coverage, observation gaps and late mutation", () => {
|
||||
const gap = denied(); gap.mutationObservation!.complete = false; expect(gradeCopilotDeniedWrite(gap).passed).toBe(false);
|
||||
const missing = denied(); missing.fileObservations.pop(); expect(gradeCopilotDeniedWrite(missing).passed).toBe(false);
|
||||
const late = denied(); late.fileObservations.at(-1)!.exists = true; expect(gradeCopilotDeniedWrite(late).passed).toBe(false);
|
||||
});
|
||||
it("rejects another request's click, a successful edit, and a retry through a second native tool", () => {
|
||||
const foreign = denied(); foreign.decision!.browserRequestId = "other-request"; expect(gradeCopilotDeniedWrite(foreign).passed).toBe(false);
|
||||
const success = denied(); success.toolResult!.status = "completed"; expect(gradeCopilotDeniedWrite(success).passed).toBe(false);
|
||||
const retried = denied(); retried.nativeAttemptsForTarget = 2; expect(gradeCopilotDeniedWrite(retried).passed).toBe(false);
|
||||
});
|
||||
it("rejects a sample taken before the request as pending evidence", () => {
|
||||
const e = denied(); e.fileObservations[1]!.providerCursor!.sourceSeq = 0; expect(gradeCopilotDeniedWrite(e).failures).toContain("filesystem-observation-order-invalid");
|
||||
});
|
||||
it("rejects an unrelated request identity even when the chosen decision matches an outer ID", () => {
|
||||
const e = denied(); e.request!.requestId = "foreign-request"; expect(gradeCopilotDeniedWrite(e).passed).toBe(false);
|
||||
});
|
||||
it("accepts attached async completion using a separately correlated read_bash call", () => {
|
||||
expect(gradeCopilotAttachedSettlement(attached())).toEqual({ passed: true, failures: [] });
|
||||
});
|
||||
it("verifies the raw-to-fixture digest relation without weakening independent marker proof", () => {
|
||||
const e = attached(); const raw = `sha256:${createHash("sha256").update(" " + e.expectedCommand).digest("hex")}`;
|
||||
e.nativeCall!.commandSha256 = raw; e.commandMatch = matchCopilotFixtureCommand(e.expectedCommand, raw);
|
||||
expect(e.commandMatch?.leadingWhitespace).toBe(" ");
|
||||
expect(gradeCopilotAttachedSettlement(e).passed).toBe(true);
|
||||
expect(e.nativeCall!.commandSha256).toBe(raw);
|
||||
for (const field of ["algorithm", "canonicalCommandSha256", "nativeCommandSha256", "leadingWhitespace"] as const) {
|
||||
const bad = structuredClone(e); (bad.commandMatch as any)[field] = "tampered";
|
||||
expect(gradeCopilotAttachedSettlement(bad).passed).toBe(false);
|
||||
}
|
||||
const missing = structuredClone(e); missing.commandMatch = null; expect(gradeCopilotAttachedSettlement(missing).passed).toBe(false);
|
||||
const forged = structuredClone(e); forged.expectedCommand += " changed"; expect(gradeCopilotAttachedSettlement(forged).passed).toBe(false);
|
||||
const unbound = structuredClone(e); unbound.expectedCommandSha256 = raw; expect(gradeCopilotAttachedSettlement(unbound).passed).toBe(false);
|
||||
const wrongExit = structuredClone(e); wrongExit.commandExit!.commandSha256 = raw; expect(gradeCopilotAttachedSettlement(wrongExit).passed).toBe(false);
|
||||
e.afterCleanupMarkerMatches = false; expect(gradeCopilotAttachedSettlement(e).passed).toBe(false);
|
||||
});
|
||||
it.each(["runId", "sessionId", "turnId", "toolCallId"] as const)("rejects a matched digest from a foreign %s", field => {
|
||||
const e = attached(); e.nativeCall![field] = "foreign";
|
||||
expect(gradeCopilotAttachedSettlement(e).passed).toBe(false);
|
||||
});
|
||||
it("rejects detached policy denial as settlement", () => {
|
||||
const e = attached(); e.nativeCall!.detach = true; expect(gradeCopilotAttachedSettlement(e).failures).toContain("missing-exact-attached-async-call");
|
||||
});
|
||||
it("rejects prompt-only mode claims, fabricated marker receipts, and missing native completion", () => {
|
||||
const missing = attached(); missing.nativeCall = null; expect(gradeCopilotAttachedSettlement(missing).passed).toBe(false);
|
||||
const fake = attached(); fake.commandExit!.ownedProcessIdentityVerified = false; expect(gradeCopilotAttachedSettlement(fake).passed).toBe(false);
|
||||
const incomplete = attached(); incomplete.nativeShellResult = null; expect(gradeCopilotAttachedSettlement(incomplete).passed).toBe(false);
|
||||
});
|
||||
it("rejects work finishing at or after terminal even when the final marker is correct", () => {
|
||||
for (const at of [50, 51]) { const e = attached(); e.commandExit!.observedAtMs = at; expect(gradeCopilotAttachedSettlement(e).passed).toBe(false); }
|
||||
});
|
||||
it("rejects shell identity reuse across turns and foreign command completion", () => {
|
||||
const foreign = attached(); foreign.nativeShellResult!.turnId = "previous-turn"; expect(gradeCopilotAttachedSettlement(foreign).passed).toBe(false);
|
||||
const other = attached(); other.nativeShellResult!.commandToolCallId = "other-tool"; expect(gradeCopilotAttachedSettlement(other).passed).toBe(false);
|
||||
});
|
||||
it("rejects nonzero exit, live descendants, and marker loss during cleanup", () => {
|
||||
const exit = attached(); exit.commandExit!.code = 1; expect(gradeCopilotAttachedSettlement(exit).passed).toBe(false);
|
||||
const live = attached(); live.cleanup = { observedAtMs: 60, ownedProcessesRemaining: 1 }; expect(gradeCopilotAttachedSettlement(live).passed).toBe(false);
|
||||
const marker = attached(); marker.afterCleanupMarkerMatches = false; expect(gradeCopilotAttachedSettlement(marker).passed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { matchCopilotFixtureCommand, validCopilotDenialSettlement, type CopilotDenialSettlement, type CopilotDenialSampleCursor, type CopilotCommandMatch } from "./copilot-protection-evidence.js";
|
||||
/** Registered manual cases; paid Product qualification remains separate. */
|
||||
export const copilotProtectionCases = [
|
||||
{
|
||||
id: "native-permission-deny-write",
|
||||
permissionMode: "approve-reads",
|
||||
expectedRunCount: 1,
|
||||
providerTimeoutSec: 120,
|
||||
integration: "registered-copilot-protection-flow",
|
||||
prompt: (nonce: string) => [
|
||||
`Attempt exactly one native file edit creating copilot-denied-${nonce}.txt with DENIED-${nonce}.`,
|
||||
"Wait for the native permission decision. If denied, do not retry through another tool, shell, path, or agent.",
|
||||
"After denial, do not invoke any further tool or workaround. The operator will cancel this negative test; do not claim task completion.",
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
id: "attached-async-settlement",
|
||||
permissionMode: "approve-all",
|
||||
expectedRunCount: 1,
|
||||
providerTimeoutSec: 120,
|
||||
integration: "registered-copilot-protection-flow",
|
||||
prompt: (nonce: string) => [
|
||||
`Run the supplied copilot-settlement-${nonce} command exactly once with the native bash tool, mode async and detach false.`,
|
||||
"Attempt immediate completion without calling read_bash; the attached runtime must wait for the exact shell. Never detach, substitute, or author its result marker yourself.",
|
||||
`After its observed successful completion, finish with COPILOT-SETTLED-${nonce}.`,
|
||||
].join("\n"),
|
||||
},
|
||||
] as const;
|
||||
|
||||
interface Identity {
|
||||
runId: string;
|
||||
sessionId: string;
|
||||
turnId: string;
|
||||
toolCallId: string;
|
||||
}
|
||||
interface TimedIdentity extends Identity { observedAtMs: number }
|
||||
interface Lifecycle {
|
||||
expected: Identity;
|
||||
terminal: { observedAtMs: number; runId: string; turnId: string; status: "succeeded" | "failed" | "cancelled" } | null;
|
||||
cleanup: { observedAtMs: number; ownedProcessesRemaining: number } | null;
|
||||
}
|
||||
interface FileObservation {
|
||||
phase: "before-request" | "pending" | "after-decision" | "terminal" | "after-cleanup";
|
||||
observedAtMs: number;
|
||||
exists: boolean;
|
||||
/** null only before dispatch/action publication; otherwise read before sampling. */
|
||||
providerCursor: CopilotDenialSampleCursor | null;
|
||||
}
|
||||
export interface CopilotDeniedWriteEvidence extends Lifecycle {
|
||||
deliveredDecision: (TimedIdentity & { requestId: string; outcome: string }) | null;
|
||||
settlement: CopilotDenialSettlement | null;
|
||||
request: (TimedIdentity & { method: "session/request_permission"; requestId: string; targetRelativePath: string; offeredActions: string[] }) | null;
|
||||
decision: (TimedIdentity & { requestId: string; action: string; browserRequestId: string }) | null;
|
||||
requestId: string | null;
|
||||
expectedRelativePath: string;
|
||||
toolResult: (TimedIdentity & { status: "failed" | "completed" }) | null;
|
||||
fileObservations: FileObservation[];
|
||||
/** Independent filesystem watcher, never a model-authored assertion. */
|
||||
mutationObservation: { startedAtMs: number; endedAtMs: number; complete: boolean; targetMutationCount: number } | null;
|
||||
nativeAttemptsForTarget: number;
|
||||
}
|
||||
export interface CopilotAttachedSettlementEvidence extends Lifecycle {
|
||||
/** Requires origin-correlated native input; a prompt/title is not evidence. */
|
||||
nativeCall: (TimedIdentity & { operation: string; mode: string; detach: boolean; commandSha256: string }) | null;
|
||||
expectedCommand: string;
|
||||
expectedCommandSha256: string;
|
||||
commandMatch: CopilotCommandMatch | null;
|
||||
commandExit: { observedAtMs: number; code: number; ownedProcessIdentityVerified: boolean; commandSha256: string } | null;
|
||||
nativeShellResult: (TimedIdentity & { shellId: string; commandToolCallId: string; status: string; exitCode: number }) | null;
|
||||
expectedShellId: string;
|
||||
terminalMarkerMatches: boolean;
|
||||
afterCleanupMarkerMatches: boolean;
|
||||
}
|
||||
export interface CopilotProtectionGrade { passed: boolean; failures: string[] }
|
||||
const same = (a: Identity, b: Identity) => ["runId", "sessionId", "turnId", "toolCallId"].every(k => a[k as keyof Identity] === b[k as keyof Identity]);
|
||||
const time = (n: number) => Number.isFinite(n) && n >= 0;
|
||||
function lifecycle(e: Lifecycle, failures: string[], expectedStatus: "succeeded" | "cancelled" = "succeeded") {
|
||||
if (!Object.values(e.expected).every(v => typeof v === "string" && v.length > 0)) failures.push("missing-origin-identity");
|
||||
if (!e.terminal || e.terminal.runId !== e.expected.runId || e.terminal.turnId !== e.expected.turnId || e.terminal.status !== expectedStatus || !time(e.terminal.observedAtMs)) failures.push("missing-expected-origin-terminal");
|
||||
if (!e.cleanup || e.cleanup.ownedProcessesRemaining !== 0 || !time(e.cleanup.observedAtMs) || !e.terminal || e.cleanup.observedAtMs < e.terminal.observedAtMs) failures.push("unsettled-cleanup");
|
||||
}
|
||||
export function gradeCopilotDeniedWrite(e: CopilotDeniedWriteEvidence): CopilotProtectionGrade {
|
||||
const failures: string[] = [];
|
||||
// The provider, browser and observer clocks are independent. Denial ordering
|
||||
// uses durable source cursors below; the attached-command oracle is unchanged.
|
||||
if (!Object.values(e.expected).every(v => typeof v === "string" && v.length > 0)) failures.push("missing-origin-identity");
|
||||
if (!e.terminal || e.terminal.runId !== e.expected.runId || e.terminal.turnId !== e.expected.turnId || e.terminal.status !== "cancelled" || !time(e.terminal.observedAtMs)) failures.push("missing-expected-origin-terminal");
|
||||
if (!e.cleanup || e.cleanup.ownedProcessesRemaining !== 0 || !time(e.cleanup.observedAtMs)) failures.push("unsettled-cleanup");
|
||||
const settlement = e.settlement;
|
||||
if (!settlement || !validCopilotDenialSettlement(settlement) || !same(settlement, e.expected) || settlement.requestId !== e.requestId
|
||||
|| !e.terminal || e.terminal.observedAtMs !== settlement.providerTerminal.emittedAtMs) failures.push("missing-explicit-settled-run-stop");
|
||||
if (!/^(?:pc-denied-[a-zA-Z0-9]+\/)?copilot-denied-[a-z0-9-]+\.txt$/.test(e.expectedRelativePath) || !e.request || !same(e.request, e.expected) || e.request.method !== "session/request_permission" || e.request.requestId !== e.requestId || e.request.targetRelativePath !== e.expectedRelativePath || !e.request.offeredActions.includes("decline") || !time(e.request.observedAtMs)) failures.push("missing-exact-native-write-request");
|
||||
if (!e.requestId || !e.decision || !same(e.decision, e.expected) || e.decision.requestId !== e.requestId || e.decision.browserRequestId !== e.requestId || e.decision.action !== "decline" || !time(e.decision.observedAtMs)) failures.push("missing-exact-browser-denial");
|
||||
if (!e.deliveredDecision || !same(e.deliveredDecision, e.expected) || e.deliveredDecision.requestId !== e.requestId || e.deliveredDecision.outcome !== "reject_once" || !time(e.deliveredDecision.observedAtMs)) failures.push("missing-delivered-native-rejection");
|
||||
if (!e.toolResult || !same(e.toolResult, e.expected) || e.toolResult.status !== "failed" || !time(e.toolResult.observedAtMs)) failures.push("missing-denied-tool-result-before-terminal");
|
||||
if (e.nativeAttemptsForTarget !== 1) failures.push("missing-or-retried-native-write");
|
||||
const phases = ["before-request", "pending", "after-decision", "terminal", "after-cleanup"] as const;
|
||||
if (phases.some(phase => !e.fileObservations.some(s => s.phase === phase)) || e.fileObservations.some(s => s.exists || !time(s.observedAtMs))) failures.push("missing-or-mutated-target-observation");
|
||||
const samples = phases.map(phase => e.fileObservations.find(s => s.phase === phase));
|
||||
const t = settlement?.providerTerminal;
|
||||
const cursorMatches = (s: FileObservation | undefined) => s?.providerCursor && t && s.providerCursor.runId === e.expected.runId
|
||||
&& s.providerCursor.turnId === e.expected.turnId && s.providerCursor.normalizedSessionId === t.normalizedSessionId
|
||||
&& s.providerCursor.sourceInstanceId === t.sourceInstanceId && Number.isSafeInteger(s.providerCursor.sourceSeq);
|
||||
if (e.fileObservations.length !== phases.length || samples.some((s, i) => !s || (i > 0 && s.observedAtMs < samples[i - 1]!.observedAtMs))
|
||||
|| samples[0]?.providerCursor !== null || !t || samples.slice(1).some(s => !cursorMatches(s))
|
||||
|| (samples[1]?.providerCursor?.sourceSeq ?? -1) < t.requestSourceSeq
|
||||
|| (samples[1]?.providerCursor?.sourceSeq ?? Infinity) >= t.resolvedSourceSeq
|
||||
|| (samples[2]?.providerCursor?.sourceSeq ?? -1) < t.failedNoticeSourceSeq
|
||||
|| (samples[3]?.providerCursor?.sourceSeq ?? -1) < t.sourceSeq || (samples[4]?.providerCursor?.sourceSeq ?? -1) < t.sourceSeq
|
||||
|| samples.slice(2).some((s, i) => (s?.providerCursor?.sourceSeq ?? -1) < (samples[i + 1]?.providerCursor?.sourceSeq ?? Infinity))
|
||||
|| !e.cleanup || samples[4]?.observedAtMs !== e.cleanup.observedAtMs) failures.push("filesystem-observation-order-invalid");
|
||||
const m = e.mutationObservation;
|
||||
if (!m || !m.complete || m.targetMutationCount !== 0 || !time(m.startedAtMs) || !time(m.endedAtMs) || !samples[0] || !samples[4] || m.startedAtMs > samples[0].observedAtMs || m.endedAtMs < samples[4].observedAtMs) failures.push("missing-or-mutated-filesystem-watch");
|
||||
return { passed: failures.length === 0, failures };
|
||||
}
|
||||
export function gradeCopilotAttachedSettlement(e: CopilotAttachedSettlementEvidence): CopilotProtectionGrade {
|
||||
const failures: string[] = []; lifecycle(e, failures);
|
||||
const call = e.nativeCall;
|
||||
const match = call ? matchCopilotFixtureCommand(e.expectedCommand, call.commandSha256) : null;
|
||||
const relationValid = match !== null && e.commandMatch != null
|
||||
&& match.canonicalCommandSha256 === e.expectedCommandSha256
|
||||
&& e.commandMatch.algorithm === match.algorithm
|
||||
&& e.commandMatch.canonicalCommandSha256 === match.canonicalCommandSha256
|
||||
&& e.commandMatch.nativeCommandSha256 === match.nativeCommandSha256
|
||||
&& e.commandMatch.leadingWhitespace === match.leadingWhitespace;
|
||||
if (!/^sha256:[a-f0-9]{64}$/.test(e.expectedCommandSha256) || !call || !same(call, e.expected) || call.operation !== "execute" || call.mode !== "async" || call.detach !== false || !relationValid || !time(call.observedAtMs)) failures.push("missing-exact-attached-async-call");
|
||||
const exit = e.commandExit;
|
||||
if (!exit || !exit.ownedProcessIdentityVerified || exit.commandSha256 !== e.expectedCommandSha256 || exit.code !== 0 || !time(exit.observedAtMs) || !call || exit.observedAtMs < call.observedAtMs || !e.terminal || exit.observedAtMs >= e.terminal.observedAtMs) failures.push("command-not-settled-before-terminal");
|
||||
const result = e.nativeShellResult;
|
||||
if (!e.expectedShellId || !result || result.runId !== e.expected.runId || result.sessionId !== e.expected.sessionId || result.turnId !== e.expected.turnId || !result.toolCallId || result.commandToolCallId !== e.expected.toolCallId || result.shellId !== e.expectedShellId || result.status !== "completed" || result.exitCode !== 0 || !time(result.observedAtMs) || !exit || result.observedAtMs < exit.observedAtMs || !e.terminal || result.observedAtMs >= e.terminal.observedAtMs) failures.push("missing-correlated-native-shell-completion");
|
||||
if (!e.terminalMarkerMatches || !e.afterCleanupMarkerMatches) failures.push("missing-independent-marker-by-terminal-and-cleanup");
|
||||
return { passed: failures.length === 0, failures };
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { observeCopilotFixtureCommand, findCopilotFixtureCommand, matchCopilotFixtureCommand, countCopilotEditOriginsForTarget, countCopilotToolOrigins, readCopilotMarkerAfterCleanup } from "./copilot-protection-evidence.js";
|
||||
import type { CopilotToolNotice } from "./copilot-evidence.js";
|
||||
const notice: CopilotToolNotice = { runId: "run", sessionId: "session", turnId: "turn", toolCallId: "edit", stage: "tool", operation: "edit", observedAtMs: 1, seq: 1 };
|
||||
describe("Copilot protection independent evidence", () => {
|
||||
it.each(["in_progress", "completed", "failed"] as const)("counts an alternate origin first seen as %s", status => {
|
||||
const rows = [{ ...notice, status: "pending" as const }, { ...notice, status }, { ...notice, toolCallId: "alternate", status }];
|
||||
expect(countCopilotToolOrigins(rows)).toBe(2);
|
||||
expect(countCopilotToolOrigins([{ ...notice, status }, { ...notice, sessionId: "other", status }])).toBe(2);
|
||||
});
|
||||
it.each(["unchanged", "changed", "deleted"])("independently reads the marker after %s cleanup", async mode => {
|
||||
const root = await mkdtemp("/tmp/pc-copilot-marker-"); const path = join(root, "marker");
|
||||
try {
|
||||
await writeFile(path, "expected"); expect(await readFile(path, "utf8")).toBe("expected");
|
||||
const matches = await readCopilotMarkerAfterCleanup(async () => {
|
||||
if (mode === "changed") await writeFile(path, "changed");
|
||||
if (mode === "deleted") await unlink(path);
|
||||
}, path, "expected");
|
||||
expect(matches).toBe(mode === "unchanged");
|
||||
} finally { await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
|
||||
import { assertCopilotRemoteRetirement, assertCopilotRemoteAttached, copilotActionNotices, copilotRemoteDeniedSample, prepareCopilotRemoteAction, type CopilotRemoteSnapshot, type CopilotRemoteFixture } from "./copilot-protection-evidence.js";
|
||||
const target = "copilot-denied-nonce.txt";
|
||||
describe("Copilot denied-target origin correlation", () => {
|
||||
// Captured native shape: updates omit the target; permission events bind it.
|
||||
const deniedTarget = "pc-denied-fixture/copilot-denied-nonce.txt";
|
||||
const captured = (): CopilotToolNotice[] => [
|
||||
{ ...notice, seq: 39, status: "pending" },
|
||||
{ ...notice, seq: 42, stage: "permission_requested", requestId: "request", target: deniedTarget, declineOffered: true },
|
||||
{ ...notice, seq: 44, stage: "permission_delivered", requestId: "request", target: deniedTarget, outcome: "reject_once" },
|
||||
{ ...notice, seq: 45, status: "failed" },
|
||||
];
|
||||
it("counts the captured denied edit across exact permission and tool origins", () => {
|
||||
expect(countCopilotEditOriginsForTarget(captured(), deniedTarget)).toBe(1);
|
||||
expect(countCopilotToolOrigins(copilotActionNotices(captured(), captured()[0]!))).toBe(1);
|
||||
});
|
||||
it.each(["runId", "sessionId", "turnId", "toolCallId"] as const)("cannot borrow a target across %s", field => {
|
||||
const rows = captured().map(n => n.stage === "tool" ? { ...n, [field]: "foreign" } : n);
|
||||
expect(countCopilotEditOriginsForTarget(rows, deniedTarget)).toBe(0);
|
||||
});
|
||||
it.each(["in_progress", "completed", "failed"] as const)("counts a retry first seen as %s", status => {
|
||||
const rows = [...captured(), { ...notice, toolCallId: "retry", seq: 46, target: deniedTarget, status }];
|
||||
expect(countCopilotEditOriginsForTarget(rows, deniedTarget)).toBe(2);
|
||||
expect(countCopilotToolOrigins(copilotActionNotices(rows, rows[0]!))).toBe(2);
|
||||
});
|
||||
it("does not hide an alternate operation with no target", () => {
|
||||
const rows = [...captured(), { ...notice, toolCallId: "alternate", seq: 46, operation: "execute" as const }];
|
||||
expect(countCopilotEditOriginsForTarget(rows, deniedTarget)).toBe(1);
|
||||
expect(countCopilotToolOrigins(copilotActionNotices(rows, rows[0]!))).toBe(2);
|
||||
});
|
||||
it("does not count permission-only or entirely unbound evidence", () => {
|
||||
expect(countCopilotEditOriginsForTarget(captured().filter(n => n.stage !== "tool"), deniedTarget)).toBe(0);
|
||||
expect(countCopilotEditOriginsForTarget(captured().filter(n => n.stage === "tool"), deniedTarget)).toBe(0);
|
||||
});
|
||||
it.each([
|
||||
{ target: "other.txt" }, { operation: "execute" as const },
|
||||
])("rejects conflicting tool evidence %j", conflict => {
|
||||
const rows = captured(); rows[3] = { ...rows[3]!, ...conflict };
|
||||
expect(() => countCopilotEditOriginsForTarget(rows, deniedTarget)).toThrow(/Conflicting/);
|
||||
});
|
||||
it("rejects another permission request on the same tool origin", () => {
|
||||
const rows = captured(); rows[2] = { ...rows[2]!, requestId: "other" };
|
||||
expect(() => countCopilotEditOriginsForTarget(rows, deniedTarget)).toThrow(/permission/);
|
||||
expect(() => countCopilotEditOriginsForTarget([...captured(), { ...captured()[1]!, seq: 46 }], deniedTarget)).toThrow(/permission/);
|
||||
});
|
||||
it("rejects a restarted or repeated native lifecycle under a reused origin", () => {
|
||||
for (const status of ["pending", "in_progress", "completed", "failed"] as const) {
|
||||
expect(() => countCopilotEditOriginsForTarget([...captured(), { ...notice, status, seq: 46 }], deniedTarget)).toThrow(/lifecycle/);
|
||||
}
|
||||
});
|
||||
});
|
||||
function receipt(): CopilotRemoteSnapshot {
|
||||
const root = { pid: 50, ppid: 1, startTicks: "200", bootId: "12345678-1234-1234-1234-123456789abc" };
|
||||
return { binding: { companyId: "company", environmentId: "env", runId: "run", leaseId: "lease", sandboxId: "sandbox", image: `image@sha256:${"a".repeat(64)}`, remoteCwd: "/home/daytona/workspace" },
|
||||
observedAtMs: 60, receivedAtMs: 61, observedMonotonicNs: "600", complete: true, workspace: {},
|
||||
targets: { [target]: { absent: true, sha256: null, complete: true, mutationCount: 0, parent: { dev: "1", ino: "2" } } },
|
||||
watcher: { complete: true, targetMutationCount: 0, workspaceMutationCount: 0 }, processes: { captured: true, root, journal: [root], live: [] },
|
||||
setup: { path: ".action.txt", sha256: `sha256:${"b".repeat(64)}`, published: true },
|
||||
attached: { connections: 1, failure: null, commandExit: { code: 0, observedAtMs: 20, observedMonotonicNs: "200" }, markerWrittenAtMs: 25, markerWrittenMonotonicNs: "250", clientExitedAtMs: 30, clientExitedMonotonicNs: "300" } };
|
||||
}
|
||||
function baseline() { const s = receipt(); s.observedAtMs = 1; s.observedMonotonicNs = "1"; s.processes.live = [50]; s.setup = { ...s.setup, published: false, sha256: null }; return s; }
|
||||
describe("Copilot sealed remote proof", () => {
|
||||
it("requires exact lease and original process identity in the retained final receipt", () => {
|
||||
expect(() => assertCopilotRemoteRetirement(receipt(), baseline())).not.toThrow();
|
||||
for (const mutate of [
|
||||
(s: CopilotRemoteSnapshot) => { s.binding.leaseId = "foreign"; },
|
||||
(s: CopilotRemoteSnapshot) => { s.processes.live = [50]; },
|
||||
(s: CopilotRemoteSnapshot) => { s.processes.root!.startTicks = "999"; },
|
||||
(s: CopilotRemoteSnapshot) => { s.processes.captured = false; },
|
||||
(s: CopilotRemoteSnapshot) => { s.processes.journal = []; },
|
||||
(s: CopilotRemoteSnapshot) => { s.setup.published = false; },
|
||||
(s: CopilotRemoteSnapshot) => { s.watcher.complete = false; },
|
||||
]) { const s = receipt(); mutate(s); expect(() => assertCopilotRemoteRetirement(s, baseline())).toThrow(); }
|
||||
});
|
||||
it("rejects transient remote writes, parent replacement and unrelated workspace changes", () => {
|
||||
expect(copilotRemoteDeniedSample(receipt(), baseline(), target, "after-cleanup").exists).toBe(false);
|
||||
for (const mutate of [
|
||||
(s: CopilotRemoteSnapshot) => { s.targets[target]!.mutationCount = 2; },
|
||||
(s: CopilotRemoteSnapshot) => { s.targets[target]!.parent.ino = "new"; },
|
||||
(s: CopilotRemoteSnapshot) => { s.targets[target]!.complete = false; },
|
||||
(s: CopilotRemoteSnapshot) => { s.watcher.workspaceMutationCount = 1; },
|
||||
(s: CopilotRemoteSnapshot) => { s.workspace.other = `sha256:${"a".repeat(64)}`; },
|
||||
]) { const s = receipt(); mutate(s); expect(() => copilotRemoteDeniedSample(s, baseline(), target, "after-cleanup")).toThrow(); }
|
||||
});
|
||||
it("requires independent attached child/client ordering and rejects early terminal", () => {
|
||||
expect(assertCopilotRemoteAttached(receipt(), baseline(), 50).connections).toBe(1);
|
||||
for (const mutate of [
|
||||
(s: CopilotRemoteSnapshot) => { s.attached!.connections = 2; },
|
||||
(s: CopilotRemoteSnapshot) => { s.attached!.commandExit!.code = 1; },
|
||||
(s: CopilotRemoteSnapshot) => { s.attached!.clientExitedMonotonicNs = "240"; },
|
||||
(s: CopilotRemoteSnapshot) => { s.attached!.markerWrittenMonotonicNs = null; },
|
||||
(s: CopilotRemoteSnapshot) => { s.attached!.clientExitedAtMs = 51; },
|
||||
]) { const s = receipt(); mutate(s); expect(() => assertCopilotRemoteAttached(s, baseline(), 50)).toThrow(); }
|
||||
expect(() => assertCopilotRemoteAttached(receipt(), baseline(), 20)).toThrow();
|
||||
});
|
||||
it("exempts only exact completed action-file reads before the tested operation", () => {
|
||||
const call = { ...notice, seq: 10, status: "in_progress" as const };
|
||||
const read = { ...notice, toolCallId: "bootstrap", operation: "read", status: "completed", seq: 1 } as CopilotToolNotice;
|
||||
const actionFile = `.paperclip-eval-action-${"a".repeat(36)}.txt`;
|
||||
const event = { runId: "run", seq: 2, protocolSchemaVersion: 1, eventType: "tool.execution.completed", payload: { prpEvent: {
|
||||
schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", runId: "run", turnId: "turn", eventType: "tool.execution.completed", emittedAt: new Date(2).toISOString(),
|
||||
payload: { schema: "paperclip.tool.execution.v1", executionId: "bootstrap", transport: "builtin", operation: "read", target: actionFile, status: "completed" },
|
||||
} } };
|
||||
read.readTargetSha256 = `sha256:${createHash("sha256").update(actionFile).digest("hex")}`;
|
||||
const proof = { actionFile, events: [event] };
|
||||
expect(countCopilotToolOrigins(copilotActionNotices([read, call], call, proof))).toBe(1);
|
||||
const wrong = structuredClone(event); wrong.payload.prpEvent.payload.target = "unrelated.txt";
|
||||
expect(() => copilotActionNotices([read, call], call, { actionFile, events: [wrong] })).toThrow();
|
||||
expect(() => copilotActionNotices([read, { ...read, toolCallId: "unrelated", seq: 3 }, call], call, proof)).toThrow();
|
||||
expect(() => copilotActionNotices([{ ...read, turnId: "other" }, call], call, proof)).toThrow();
|
||||
for (const bad of [{ ...read, seq: 11 }, { ...read, operation: undefined }, { ...read, operation: "edit" as const }]) {
|
||||
expect(countCopilotToolOrigins(copilotActionNotices([bad, call], call, { actionFile, events: [] }))).toBe(2);
|
||||
}
|
||||
expect(countCopilotToolOrigins(copilotActionNotices([read, call], call))).toBe(2);
|
||||
});
|
||||
it("awaits the remote fixture and baseline before disclosing the actual command", async () => {
|
||||
const s = baseline(), order: string[] = [];
|
||||
const fixture: CopilotRemoteFixture = { binding: s.binding, remoteCwd: s.binding.remoteCwd, actionFile: s.setup.path,
|
||||
killOwnedCopilot: async () => { throw new Error("Unexpected provider-death dispatch"); },
|
||||
snapshot: async () => { await Promise.resolve(); order.push("baseline"); return s; },
|
||||
setupAttachedCommand: async input => { expect(input.markerText).toBe("private-marker"); order.push("setup"); return { command: "exact-remote-command", commandSha256: `sha256:${"c".repeat(64)}` }; },
|
||||
finish: async () => { throw new Error("must not finish during setup"); }, readFile: async () => { throw new Error("must not read host file"); }, close: async () => {} };
|
||||
const prepared = await prepareCopilotRemoteAction({ fixture, companyId: "company", environmentId: "env", runId: "run", target, prompt: "test", markerText: "private-marker" });
|
||||
order.push("publish"); expect(order).toEqual(["setup", "baseline", "publish"]);
|
||||
expect(prepared.prompt).toContain("exact-remote-command"); expect(prepared.prompt).not.toContain("private-marker");
|
||||
await expect(prepareCopilotRemoteAction({ fixture, companyId: "company", environmentId: "env", runId: "other", target, prompt: "test" })).rejects.toThrow(/Foreign/);
|
||||
s.targets[target]!.absent = false;
|
||||
await expect(prepareCopilotRemoteAction({ fixture, companyId: "company", environmentId: "env", runId: "run", target, prompt: "test" })).rejects.toThrow(/present/);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses sealed retained bytes after lease deletion, rejects changed/deleted markers, and never snapshots again", async () => {
|
||||
const { readCopilotRemoteMarkerAfterRetirement } = await import("./copilot-protection-evidence.js");
|
||||
const { createHash } = await import("node:crypto");
|
||||
const end = receipt(); end.targets[target] = { ...end.targets[target]!, absent: false, sha256: `sha256:${createHash("sha256").update("marker").digest("hex")}` };
|
||||
let finished = false, bytes: Buffer | undefined = Buffer.from("marker");
|
||||
const fixture = {
|
||||
finish: async () => { finished = true; return end; },
|
||||
snapshot: async () => { throw new Error("lease destroyed: RPC forbidden"); },
|
||||
readFile: async () => { if (!finished) throw new Error("not retained yet"); if (!bytes) throw new Error("terminal_file_missing"); return Buffer.from(bytes); },
|
||||
} as unknown as CopilotRemoteFixture;
|
||||
expect(await readCopilotRemoteMarkerAfterRetirement(fixture, baseline(), target, "marker")).toBe(true);
|
||||
bytes = Buffer.from("changed"); expect(await readCopilotRemoteMarkerAfterRetirement(fixture, baseline(), target, "marker")).toBe(false);
|
||||
bytes = undefined; await expect(readCopilotRemoteMarkerAfterRetirement(fixture, baseline(), target, "marker")).rejects.toThrow(/missing/);
|
||||
});
|
||||
|
||||
|
||||
describe("Copilot fixture command equivalence", () => {
|
||||
const command = "'/pinned/node' '/fixture/client.cjs' '/fixture/private/socket' 'nonce'";
|
||||
const digest = (value: string) => `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
||||
const captured = (prefix = " "): CopilotToolNotice[] => [
|
||||
{ ...notice, operation: "execute", mode: "async", detach: false, commandSha256: digest(prefix + command), status: "pending", seq: 38 },
|
||||
{ ...notice, operation: "execute", mode: "async", detach: false, commandSha256: digest(prefix + command), status: "completed", shellId: "0", shellState: "started", commandToolCallId: notice.toolCallId, seq: 54 },
|
||||
{ ...notice, operation: "read", toolCallId: "shell-read", status: "completed", shellId: "0", shellState: "completed", commandToolCallId: notice.toolCallId, exitCode: 0, seq: 105 },
|
||||
];
|
||||
it.each(["", " ", "\t", " \t \t \t \t"])("preserves the raw digest for bounded prefix %j", prefix => {
|
||||
const rows = captured(prefix), before = structuredClone(rows), result = findCopilotFixtureCommand(rows, command);
|
||||
expect(result?.match).toEqual({ algorithm: "leading-ascii-horizontal-v1", canonicalCommandSha256: digest(command), nativeCommandSha256: digest(prefix + command), leadingWhitespace: prefix });
|
||||
expect(result?.call).toBe(rows[0]); expect(rows).toEqual(before);
|
||||
});
|
||||
it.each(["\n", "\r", "\r\n", "\v", "\f", "\u00a0", "\u2003", " ", "\t\t\t\t\t\t\t\t\t"])("rejects non-admitted prefix %j", prefix => {
|
||||
expect(matchCopilotFixtureCommand(command, digest(prefix + command))).toBeNull();
|
||||
});
|
||||
it.each([
|
||||
command + " ", command + "\t", command.replace("node' '", "node' '"), command.replaceAll("'", '"'),
|
||||
command.replace("client.cjs", "other.cjs"), command.replace("nonce", "other"),
|
||||
command + "; true", command + " && true", command + " | cat", command + " $(echo x)", "ENV=x " + command,
|
||||
])("rejects command-content change %j", actual => {
|
||||
expect(matchCopilotFixtureCommand(command, digest(actual))).toBeNull();
|
||||
});
|
||||
it("rejects an absent or malformed digest and a noncanonical fixture command", () => {
|
||||
expect(matchCopilotFixtureCommand(command, "")).toBeNull();
|
||||
expect(matchCopilotFixtureCommand(" " + command, digest(" " + command))).toBeNull();
|
||||
expect(findCopilotFixtureCommand([], command)).toBeNull();
|
||||
expect(findCopilotFixtureCommand(captured().map(n => ({ ...n, commandSha256: undefined })), command)).toBeNull();
|
||||
});
|
||||
it.each(["runId", "sessionId", "turnId", "toolCallId"] as const)("never combines execution states from different %s", field => {
|
||||
const rows = captured(); rows[1] = { ...rows[1]!, [field]: "foreign" };
|
||||
expect(findCopilotFixtureCommand(rows, command)).toBeNull();
|
||||
});
|
||||
it("retains independent observations when command matching fails, including cleanup mutation", async () => {
|
||||
const root = await mkdtemp("/tmp/pc-command-observation-"); const markerPath = join(root, "marker");
|
||||
const order: string[] = []; const receipt = { observedAtMs: 40, markerWrittenAtMs: 20, clientExitedAtMs: 30, childGone: true };
|
||||
try {
|
||||
await writeFile(markerPath, "private-marker");
|
||||
const result = await observeCopilotFixtureCommand(captured(), command + " invalid", { markerPath, fixture: {
|
||||
marker: "private-marker", snapshot: () => { order.push("snapshot"); return receipt; },
|
||||
close: async () => { order.push("close"); await writeFile(markerPath, "changed-during-cleanup"); },
|
||||
} });
|
||||
expect(result).toEqual({ external: receipt, markerMatches: true, afterCleanupMarkerMatches: false, matched: null });
|
||||
expect(order).toEqual(["snapshot", "close"]);
|
||||
expect(result.external?.observedAtMs).toBe(40); // Original observation time, never cleanup time.
|
||||
} finally { await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
it("rejects retries, another command, conflicting input and reused lifecycle", () => {
|
||||
for (const extra of [
|
||||
{ ...captured()[0]!, toolCallId: "retry" },
|
||||
{ ...captured()[1]!, toolCallId: "retry" },
|
||||
{ ...captured()[0]!, toolCallId: "other", commandSha256: digest(command + " other") },
|
||||
{ ...captured()[0]!, seq: 39 }, { ...captured()[1]!, seq: 55 },
|
||||
{ ...captured()[0]!, status: "in_progress" as const, seq: 55 },
|
||||
]) expect(findCopilotFixtureCommand([...captured(), extra], command)).toBeNull();
|
||||
for (const conflict of [{ commandSha256: digest(command) }, { mode: "sync" as const }, { detach: true }, { operation: "edit" as const }, { status: "failed" as const }]) {
|
||||
const rows = captured(); rows[1] = { ...rows[1]!, ...conflict };
|
||||
expect(findCopilotFixtureCommand(rows, command)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,412 @@
|
||||
import { assertCopilotStopAcknowledgement, type CopilotStopAcknowledgement } from "./copilot-stop-acknowledgement.js";
|
||||
import { withoutProvenBootstrapReads, bootstrapReadExecutionId, type BootstrapReadProof } from "./native-bootstrap-read-proof.js";
|
||||
import { canonicalJson } from "../../packages/shared/src/portability-hash.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { readCopilotToolEvidence, type CopilotToolNotice } from "./copilot-evidence.js";
|
||||
import { readCopilotContextRead } from "./copilot-context-evidence.js";
|
||||
|
||||
/** A tool origin exists regardless of the first status emitted by the provider. */
|
||||
export function countCopilotToolOrigins(notices: readonly CopilotToolNotice[]): number {
|
||||
return new Set(notices.filter(n => n.stage === "tool").map(n => JSON.stringify([n.runId, n.sessionId, n.turnId, n.toolCallId]))).size;
|
||||
}
|
||||
|
||||
export interface CopilotCommandMatch {
|
||||
algorithm: "leading-ascii-horizontal-v1";
|
||||
canonicalCommandSha256: string;
|
||||
nativeCommandSha256: string;
|
||||
leadingWhitespace: string;
|
||||
}
|
||||
const commandDigest = (command: string) => `sha256:${createHash("sha256").update(command).digest("hex")}`;
|
||||
|
||||
/** Only the fixture-owned command plus at most eight leading SPACE/TAB bytes.
|
||||
* Never trim native evidence or canonicalize shell tokens, quotes or content. */
|
||||
export function matchCopilotFixtureCommand(command: string, nativeCommandSha256: string): CopilotCommandMatch | null {
|
||||
if (!command || /^[ \t\r\n]/u.test(command) || !/^sha256:[a-f0-9]{64}$/u.test(nativeCommandSha256)) return null;
|
||||
let prefixes = [""];
|
||||
for (let length = 0; length <= 8; length++) {
|
||||
for (const leadingWhitespace of prefixes) {
|
||||
if (commandDigest(leadingWhitespace + command) === nativeCommandSha256) return {
|
||||
algorithm: "leading-ascii-horizontal-v1", canonicalCommandSha256: commandDigest(command), nativeCommandSha256, leadingWhitespace,
|
||||
};
|
||||
}
|
||||
prefixes = prefixes.flatMap(prefix => [prefix + " ", prefix + "\t"]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Bind the relation to one complete native execution, including retry checks. */
|
||||
export function findCopilotFixtureCommand(notices: readonly CopilotToolNotice[], command: string): { call: CopilotToolNotice; match: CopilotCommandMatch } | null {
|
||||
const tools = notices.filter(n => n.stage === "tool");
|
||||
const key = (n: CopilotToolNotice) => JSON.stringify([n.runId, n.sessionId, n.turnId, n.toolCallId]);
|
||||
const executions = tools.filter(n => n.operation === "execute" || n.commandSha256 !== undefined);
|
||||
if (new Set(executions.map(key)).size !== 1) return null;
|
||||
const first = executions[0]!;
|
||||
if (![first.runId, first.sessionId, first.turnId, first.toolCallId].every(Boolean)) return null;
|
||||
const group = tools.filter(n => key(n) === key(first));
|
||||
const pending = group.filter(n => n.status === "pending"), terminal = group.filter(n => n.status === "completed" || n.status === "failed");
|
||||
if (pending.length !== 1 || terminal.length !== 1 || terminal[0]!.status !== "completed"
|
||||
|| group.some(n => n.seq < pending[0]!.seq || n.seq > terminal[0]!.seq)
|
||||
|| pending[0]!.seq >= terminal[0]!.seq) return null;
|
||||
const call = pending[0]!, match = matchCopilotFixtureCommand(command, call.commandSha256 ?? "");
|
||||
if (!match || call.operation !== "execute" || call.mode !== "async" || call.detach !== false
|
||||
|| group.some(n => (n.commandSha256 !== undefined && n.commandSha256 !== call.commandSha256)
|
||||
|| (n.operation !== undefined && n.operation !== "execute") || (n.mode !== undefined && n.mode !== "async")
|
||||
|| (n.detach !== undefined && n.detach !== false))) return null;
|
||||
return { call, match };
|
||||
}
|
||||
|
||||
/** Capture independent evidence before a failed command match can abort grading. */
|
||||
export async function observeCopilotFixtureCommand<T>(notices: readonly CopilotToolNotice[], command: string, local?: {
|
||||
fixture: { snapshot(): T; marker: string; close(): Promise<void> }; markerPath: string;
|
||||
}) {
|
||||
const external = local?.fixture.snapshot();
|
||||
const markerMatches = local ? await readCopilotMarkerAfterCleanup(async () => {}, local.markerPath, local.fixture.marker) : undefined;
|
||||
const afterCleanupMarkerMatches = local ? await readCopilotMarkerAfterCleanup(() => local.fixture.close(), local.markerPath, local.fixture.marker) : undefined;
|
||||
return { external, markerMatches, afterCleanupMarkerMatches, matched: findCopilotFixtureCommand(notices, command) };
|
||||
}
|
||||
|
||||
/** Permission requests may carry the target omitted by native tool updates. */
|
||||
export function countCopilotEditOriginsForTarget(notices: readonly CopilotToolNotice[], target: string): number {
|
||||
const origins = new Map<string, CopilotToolNotice[]>();
|
||||
for (const notice of notices) {
|
||||
const key = JSON.stringify([notice.runId, notice.sessionId, notice.turnId, notice.toolCallId]);
|
||||
const group = origins.get(key) ?? [];
|
||||
group.push(notice); origins.set(key, group);
|
||||
}
|
||||
let count = 0;
|
||||
for (const group of origins.values()) {
|
||||
if (!group.some(n => n.target === target)) continue;
|
||||
if (group.some(n => (n.target !== undefined && n.target !== target) || (n.operation !== undefined && n.operation !== "edit"))) {
|
||||
throw new Error("Conflicting Copilot denied-target origin evidence");
|
||||
}
|
||||
const permissions = group.filter(n => n.stage !== "tool");
|
||||
const requestIds = new Set(permissions.map(n => n.requestId));
|
||||
if (requestIds.has(undefined) || requestIds.size > 1
|
||||
|| permissions.filter(n => n.stage === "permission_requested").length > 1
|
||||
|| permissions.filter(n => n.stage === "permission_delivered").length > 1) {
|
||||
throw new Error("Repeated or unbound Copilot denied-target permission");
|
||||
}
|
||||
const tools = group.filter(n => n.stage === "tool");
|
||||
const terminal = tools.filter(n => n.status === "completed" || n.status === "failed");
|
||||
if (terminal.length > 1 || tools.filter(n => n.status === "pending").length > 1
|
||||
|| (terminal[0] && tools.some(n => n.seq > terminal[0]!.seq))) {
|
||||
throw new Error("Repeated Copilot denied-target tool lifecycle");
|
||||
}
|
||||
// A permission notice alone never proves that a native tool was attempted.
|
||||
if (tools.some(n => n.operation === "edit")) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Re-read independently after cleanup; a pre-cleanup value is not evidence. */
|
||||
export async function readCopilotMarkerAfterCleanup(close: () => Promise<void>, path: string, expected: string): Promise<boolean> {
|
||||
await close();
|
||||
try { return await readFile(path, "utf8") === expected; }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
|
||||
}
|
||||
|
||||
export interface CopilotRemoteBinding {
|
||||
companyId: string; environmentId: string; runId: string; leaseId: string; sandboxId: string; image: string; remoteCwd: string;
|
||||
}
|
||||
export interface CopilotRemoteSnapshot {
|
||||
binding: CopilotRemoteBinding; observedAtMs: number; receivedAtMs: number; observedMonotonicNs: string; complete: boolean;
|
||||
workspace: Record<string, string>;
|
||||
targets: Record<string, { absent: boolean; sha256: string | null; parent: { dev: string; ino: string }; mutationCount: number; complete: boolean }>;
|
||||
watcher: { complete: boolean; targetMutationCount: number; workspaceMutationCount: number };
|
||||
processes: { captured: boolean; root: { pid: number; startTicks: string; bootId: string } | null; journal: Array<{ pid: number; ppid: number; startTicks: string; bootId: string }>; live: number[] };
|
||||
setup: { path: string; sha256: string | null; published: boolean };
|
||||
attached: { connections: number; failure: string | null; commandExit: { code: number; observedAtMs: number; observedMonotonicNs: string } | null;
|
||||
markerWrittenAtMs: number | null; markerWrittenMonotonicNs: string | null; clientExitedAtMs: number | null; clientExitedMonotonicNs: string | null } | null;
|
||||
}
|
||||
export interface CopilotRemoteFixture {
|
||||
binding: CopilotRemoteBinding; remoteCwd: string; actionFile: string;
|
||||
snapshot(label: string): Promise<CopilotRemoteSnapshot>;
|
||||
killOwnedCopilot(): Promise<Record<string, unknown>>;
|
||||
setupAttachedCommand(input: { marker: string; markerText: string; delayMs: number }): Promise<{ command: string; commandSha256: string }>;
|
||||
finish(): Promise<CopilotRemoteSnapshot>; readFile(relative: string): Promise<Buffer>; close(): Promise<void>;
|
||||
}
|
||||
export interface CopilotRemoteBootstrap {
|
||||
prompt(nonce: string): string;
|
||||
bindAndRelease(input: { issueId: string; runId: string; targets: readonly string[];
|
||||
actionPrompt(fixture: CopilotRemoteFixture): Promise<string> | string }): Promise<CopilotRemoteFixture>;
|
||||
}
|
||||
export function assertCopilotRemoteSnapshot(s: CopilotRemoteSnapshot, binding: CopilotRemoteBinding): void {
|
||||
const keys: Array<keyof CopilotRemoteBinding> = ["companyId", "environmentId", "runId", "leaseId", "sandboxId", "image", "remoteCwd"];
|
||||
if (!s.complete || !s.watcher.complete || !keys.every(k => typeof binding[k] === "string" && binding[k].length > 0 && s.binding[k] === binding[k])
|
||||
|| !/^.+@sha256:[a-f0-9]{64}$/u.test(binding.image) || !binding.remoteCwd.startsWith("/") || binding.remoteCwd.split("/").some(p => p === ".." || p === ".")
|
||||
|| !Number.isSafeInteger(s.observedAtMs) || s.observedAtMs < 0 || !Number.isSafeInteger(s.receivedAtMs) || !/^\d+$/u.test(s.observedMonotonicNs)
|
||||
|| ![s.watcher.targetMutationCount, s.watcher.workspaceMutationCount].every(n => Number.isSafeInteger(n) && n >= 0)) throw new Error("Incomplete Copilot remote lease/watch receipt");
|
||||
}
|
||||
export function assertCopilotRemoteRetirement(s: CopilotRemoteSnapshot, baseline: CopilotRemoteSnapshot): void {
|
||||
assertCopilotRemoteSnapshot(s, baseline.binding);
|
||||
const root = s.processes.root, original = baseline.processes.root;
|
||||
if (!root || !original || !baseline.processes.captured || !s.processes.captured || s.processes.live.length !== 0
|
||||
|| root.pid !== original.pid || root.startTicks !== original.startTicks || root.bootId !== original.bootId
|
||||
|| !Number.isSafeInteger(root.pid) || root.pid < 2 || !/^\d+$/u.test(root.startTicks) || !/^[a-f0-9-]{36}$/iu.test(root.bootId)
|
||||
|| !s.processes.journal.some(p => p.pid === root.pid && p.startTicks === root.startTicks && p.bootId === root.bootId)
|
||||
|| !s.processes.journal.every(p => p.bootId === root.bootId && /^\d+$/u.test(p.startTicks) && Number.isSafeInteger(p.pid) && p.pid > 1)
|
||||
|| !s.setup.published || s.setup.path !== baseline.setup.path || !/^sha256:[a-f0-9]{64}$/u.test(s.setup.sha256 ?? "")
|
||||
|| BigInt(s.observedMonotonicNs) < BigInt(baseline.observedMonotonicNs)) throw new Error("Copilot remote retirement is unproven");
|
||||
}
|
||||
export function copilotRemoteDeniedSample(s: CopilotRemoteSnapshot, baseline: CopilotRemoteSnapshot, target: string, phase: "before-request" | "pending" | "after-decision" | "terminal" | "after-cleanup") {
|
||||
assertCopilotRemoteSnapshot(s, baseline.binding);
|
||||
const t = s.targets[target], before = baseline.targets[target];
|
||||
if (!t?.complete || !before?.complete || !/^\d+$/u.test(t.parent.dev) || !/^\d+$/u.test(t.parent.ino)
|
||||
|| t.parent.dev !== before.parent.dev || t.parent.ino !== before.parent.ino || t.mutationCount !== 0
|
||||
|| s.watcher.targetMutationCount !== 0 || s.watcher.workspaceMutationCount !== baseline.watcher.workspaceMutationCount
|
||||
|| JSON.stringify(Object.entries(s.workspace).sort()) !== JSON.stringify(Object.entries(baseline.workspace).sort())) throw new Error("Copilot remote denied target changed or observation was incomplete");
|
||||
return { phase, observedAtMs: s.observedAtMs, exists: t.absent !== true || t.sha256 !== null };
|
||||
}
|
||||
/** Only typed reads before the tested operation can be bootstrap work. */
|
||||
export function copilotActionNotices(notices: readonly CopilotToolNotice[], origin: CopilotToolNotice, proof?: BootstrapReadProof, contextEvents?: readonly unknown[]): CopilotToolNotice[] {
|
||||
const context = contextEvents ? readCopilotContextRead(contextEvents, origin, true) : undefined;
|
||||
const remaining = context ? notices.filter(n => ![context.toolCallId, ...context.discoveries.map(d => d.toolCallId)].includes(n.toolCallId)) : notices;
|
||||
const bootstrap = proof && context ? { ...proof, events: proof.events.filter(r => ![...context.canonicalSeqs, ...context.discoveries.flatMap(d => d.canonicalSeqs)].includes((r as { seq: number }).seq)) } : proof;
|
||||
return withoutProvenBootstrapReads(remaining, origin, bootstrap);
|
||||
}
|
||||
export function assertCopilotRemoteAttached(s: CopilotRemoteSnapshot, baseline: CopilotRemoteSnapshot, terminalAt: number) {
|
||||
assertCopilotRemoteRetirement(s, baseline);
|
||||
const a = s.attached;
|
||||
if (!a || a.failure !== null || a.connections !== 1 || a.commandExit?.code !== 0 || a.markerWrittenAtMs === null || a.clientExitedAtMs === null
|
||||
|| !/^\d+$/u.test(a.commandExit.observedMonotonicNs) || !/^\d+$/u.test(a.markerWrittenMonotonicNs ?? "") || !/^\d+$/u.test(a.clientExitedMonotonicNs ?? "")
|
||||
|| BigInt(a.commandExit.observedMonotonicNs) > BigInt(a.markerWrittenMonotonicNs!) || BigInt(a.markerWrittenMonotonicNs!) > BigInt(a.clientExitedMonotonicNs!)
|
||||
|| BigInt(a.clientExitedMonotonicNs!) > BigInt(s.observedMonotonicNs)
|
||||
|| ![terminalAt, a.commandExit.observedAtMs, a.markerWrittenAtMs, a.clientExitedAtMs].every(n => Number.isSafeInteger(n) && n >= 0)
|
||||
|| BigInt(a.commandExit.observedMonotonicNs) < BigInt(baseline.observedMonotonicNs)
|
||||
|| a.commandExit.observedAtMs >= terminalAt || a.markerWrittenAtMs >= terminalAt || a.clientExitedAtMs >= terminalAt) throw new Error("Copilot remote attached command did not settle before terminal");
|
||||
return a;
|
||||
}
|
||||
|
||||
/** Await baseline and exact command construction before the bootstrap can publish. */
|
||||
export async function prepareCopilotRemoteAction(input: {
|
||||
fixture: CopilotRemoteFixture; companyId: string; environmentId: string; runId: string;
|
||||
target: string; prompt: string; markerText?: string;
|
||||
}) {
|
||||
const f = input.fixture;
|
||||
if (f.binding.companyId !== input.companyId || f.binding.environmentId !== input.environmentId || f.binding.runId !== input.runId || f.remoteCwd !== f.binding.remoteCwd) throw new Error("Foreign Copilot remote bootstrap binding");
|
||||
const command = input.markerText === undefined ? undefined : await f.setupAttachedCommand({ marker: input.target, markerText: input.markerText, delayMs: 4000 });
|
||||
const baseline = await f.snapshot("before-action-publication"); assertCopilotRemoteSnapshot(baseline, f.binding);
|
||||
if (baseline.setup.published || baseline.setup.path !== f.actionFile || !baseline.processes.captured || baseline.processes.live.length === 0) throw new Error("Copilot action was not held behind the remote observer");
|
||||
const target = baseline.targets[input.target];
|
||||
if (!target?.complete || !target.absent || target.sha256 !== null || target.mutationCount !== 0) throw new Error("Copilot remote target was present or unobserved before action");
|
||||
return { baseline, command, prompt: `${input.prompt}\nThe admitted remote workspace is ${f.remoteCwd}.${command ? `\nThe exact supplied command is:\n${command.command}\nDo not inspect or modify fixture code, fabricate its marker, or launch a substitute command.` : ""}` };
|
||||
}
|
||||
|
||||
/** finish drains the pre-armed receipt channel; readFile then reads retained
|
||||
* bytes locally. Never snapshot or issue a sandbox RPC after lease retirement. */
|
||||
export async function readCopilotRemoteMarkerAfterRetirement(fixture: CopilotRemoteFixture, baseline: CopilotRemoteSnapshot, target: string, expected: string): Promise<boolean> {
|
||||
const receipt = await fixture.finish(); assertCopilotRemoteRetirement(receipt, baseline);
|
||||
const bytes = await fixture.readFile(target);
|
||||
return bytes.toString("utf8") === expected && receipt.targets[target]?.sha256 === `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
||||
}
|
||||
|
||||
export interface CopilotDenialSettlement {
|
||||
stopAcknowledgement: CopilotStopAcknowledgement;
|
||||
schema: "paperclip.e2e.copilot-denial-settlement.v4";
|
||||
branch: "provider_cancelled_or_interrupted" | "provider_completed_observed_before_stop";
|
||||
/** A cancelled terminal alone does not prove Stop reached active work. */
|
||||
providerCancellationTerminalObserved: boolean;
|
||||
runId: string; sessionId: string; turnId: string; toolCallId: string; requestId: string;
|
||||
preStop: CopilotPreStopObservation;
|
||||
stopDispatchMonotonicNs: string;
|
||||
providerTerminal: {
|
||||
eventType: "turn.completed" | "turn.cancelled" | "turn.interrupted";
|
||||
normalizedSessionId: string; sourceInstanceId: string;
|
||||
requestSourceSeq: number; resolvedSourceSeq: number; deliveredSourceSeq: number; failedNoticeSourceSeq: number;
|
||||
failedToolSourceSeq: number; failedToolRowCreatedAtMs: number; sourceSeq: number;
|
||||
emittedAtMs: number; rowCreatedAtMs: number; rowSha256: string; failedToolRowSha256: string;
|
||||
};
|
||||
runStop: {
|
||||
companyId: string; issueId: string; scope: "run"; status: "cancelled"; issueStatus: "in_progress";
|
||||
intentId: string; intentAuditId: string; acknowledgementAuditId: string;
|
||||
requestedAtMs: number; recordedAtMs: number; acknowledgedAtMs: number; finishedAtMs: number;
|
||||
};
|
||||
}
|
||||
const settlementRecord = (v: unknown): Record<string, any> => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Record<string, any> : {};
|
||||
const settlementId = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(v) && !v.includes("[REDACTED]");
|
||||
const settlementTime = (v: unknown): v is number => Number.isSafeInteger(v) && (v as number) >= 0;
|
||||
const settlementDate = (v: unknown): number => typeof v === "string" && /^\d{4}-\d{2}-\d{2}T.*Z$/u.test(v) ? Date.parse(v) : NaN;
|
||||
|
||||
export function validCopilotDenialSettlement(s: CopilotDenialSettlement): boolean {
|
||||
if (!s || s.schema !== "paperclip.e2e.copilot-denial-settlement.v4") return false;
|
||||
const t = s.providerTerminal, c = s.runStop;
|
||||
if (!t || !c || ![s.runId, s.sessionId, s.turnId, s.toolCallId, s.requestId, t.normalizedSessionId, t.sourceInstanceId,
|
||||
c.companyId, c.issueId, c.intentId, c.intentAuditId, c.acknowledgementAuditId].every(settlementId)
|
||||
|| c.scope !== "run" || c.status !== "cancelled" || c.issueStatus !== "in_progress" || c.intentAuditId === c.acknowledgementAuditId
|
||||
|| ![t.failedToolRowCreatedAtMs, t.emittedAtMs, t.rowCreatedAtMs, c.requestedAtMs, c.recordedAtMs, c.acknowledgedAtMs, c.finishedAtMs].every(settlementTime)
|
||||
|| ![t.requestSourceSeq, t.resolvedSourceSeq, t.deliveredSourceSeq, t.failedNoticeSourceSeq].every(n => Number.isSafeInteger(n) && n > 0)
|
||||
|| !(t.requestSourceSeq < t.resolvedSourceSeq && t.resolvedSourceSeq < t.deliveredSourceSeq && t.deliveredSourceSeq < t.failedNoticeSourceSeq && t.failedNoticeSourceSeq < t.failedToolSourceSeq)
|
||||
|| !Number.isSafeInteger(t.failedToolSourceSeq) || t.failedToolSourceSeq <= 0 || !Number.isSafeInteger(t.sourceSeq) || t.sourceSeq <= t.failedToolSourceSeq
|
||||
|| c.requestedAtMs > c.recordedAtMs || c.recordedAtMs > c.acknowledgedAtMs || c.acknowledgedAtMs > c.finishedAtMs) return false;
|
||||
const before = s.preStop;
|
||||
if (!before || before.schema !== "paperclip.e2e.copilot-pre-stop-observation.v2"
|
||||
|| !["runId", "sessionId", "turnId", "toolCallId", "requestId"].every(k => before[k as keyof CopilotPreStopObservation] === s[k as keyof CopilotDenialSettlement])
|
||||
|| typeof before.cancellationRequestId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test(before.cancellationRequestId)
|
||||
|| typeof s.stopDispatchMonotonicNs !== "string" || !/^[1-9][0-9]{0,29}$/.test(s.stopDispatchMonotonicNs)
|
||||
|| !s.stopAcknowledgement || s.stopAcknowledgement.schema !== "paperclip.e2e.copilot-stop-acknowledgement.v1"
|
||||
|| s.stopAcknowledgement.fixtureCorrelationId !== before.cancellationRequestId || s.stopAcknowledgement.dispatchMonotonicNs !== s.stopDispatchMonotonicNs
|
||||
|| s.stopAcknowledgement.intentId !== c.intentId || s.stopAcknowledgement.intentAuditId !== c.intentAuditId || s.stopAcknowledgement.acknowledgementAuditId !== c.acknowledgementAuditId
|
||||
|| s.stopAcknowledgement.companyId !== c.companyId || s.stopAcknowledgement.issueId !== c.issueId || s.stopAcknowledgement.runId !== s.runId || s.stopAcknowledgement.callerUserId !== "local-board"
|
||||
|| !/^sha256:[a-f0-9]{64}$/.test(s.stopAcknowledgement.responseMetadataSha256)
|
||||
|| !/^[1-9][0-9]{0,29}$/.test(s.stopAcknowledgement.apiResponseObservedMonotonicNs) || BigInt(s.stopAcknowledgement.apiResponseObservedMonotonicNs) <= BigInt(s.stopDispatchMonotonicNs)
|
||||
|| before.companyId !== c.companyId || before.normalizedSessionId !== t.normalizedSessionId || before.sourceInstanceId !== t.sourceInstanceId
|
||||
|| before.failedToolSourceSeq !== t.failedToolSourceSeq || before.failedToolRowSha256 !== t.failedToolRowSha256
|
||||
|| ![t.rowSha256, t.failedToolRowSha256].every(v => /^sha256:[a-f0-9]{64}$/u.test(v))
|
||||
|| typeof before.apiReadCompletedMonotonicNs !== "string" || typeof s.stopDispatchMonotonicNs !== "string"
|
||||
|| !/^[1-9][0-9]{0,29}$/u.test(before.apiReadCompletedMonotonicNs) || !/^[1-9][0-9]{0,29}$/u.test(s.stopDispatchMonotonicNs)
|
||||
|| BigInt(before.apiReadCompletedMonotonicNs) >= BigInt(s.stopDispatchMonotonicNs)) return false;
|
||||
if (before.terminal !== null && (!before.terminal || before.terminal.eventType !== t.eventType || before.terminal.sourceSeq !== t.sourceSeq || before.terminal.rowSha256 !== t.rowSha256)) return false;
|
||||
if (s.branch === "provider_completed_observed_before_stop") return t.eventType === "turn.completed"
|
||||
&& s.providerCancellationTerminalObserved === false && before.terminal?.eventType === "turn.completed";
|
||||
return s.branch === "provider_cancelled_or_interrupted" && s.providerCancellationTerminalObserved === true
|
||||
&& ["turn.cancelled", "turn.interrupted"].includes(t.eventType);
|
||||
}
|
||||
|
||||
/** Same failed-edit proof used before Stop and during final settlement. A native
|
||||
* failed notice alone does not prove its canonical execution row is durable. */
|
||||
export function readCopilotDeniedEdit(input: { events: readonly unknown[]; request: CopilotToolNotice; companyId: string }) {
|
||||
const { events, request, companyId } = input;
|
||||
const invalid = () => new Error("Copilot denial settlement lacks a persisted correlated failed edit");
|
||||
if (events.length > 20_000 || !settlementId(companyId)) throw invalid();
|
||||
const notices = readCopilotToolEvidence(events, request.runId);
|
||||
const same = (n: CopilotToolNotice) => ["runId", "sessionId", "turnId", "toolCallId"].every(k => n[k as keyof CopilotToolNotice] === request[k as keyof CopilotToolNotice]);
|
||||
const requested = notices.filter(n => n.stage === "permission_requested" && same(n));
|
||||
const delivered = notices.filter(n => n.stage === "permission_delivered" && same(n));
|
||||
const failed = notices.filter(n => n.stage === "tool" && same(n) && ["failed", "completed"].includes(n.status ?? ""));
|
||||
if (requested.length !== 1 || requested[0]!.seq !== request.seq || requested[0]!.requestId !== request.requestId || request.operation !== "edit"
|
||||
|| delivered.length !== 1 || delivered[0]!.requestId !== request.requestId || delivered[0]!.outcome !== "reject_once"
|
||||
|| failed.length !== 1 || failed[0]!.status !== "failed" || requested[0]!.seq >= delivered[0]!.seq || delivered[0]!.seq >= failed[0]!.seq) throw invalid();
|
||||
const rows = events.map(settlementRecord);
|
||||
const get = (row: Record<string, any>) => {
|
||||
const f = settlementRecord(settlementRecord(row.payload).prpEvent);
|
||||
if (row.runId !== request.runId || row.companyId !== companyId || !Number.isSafeInteger(row.seq) || row.seq <= 0
|
||||
|| f.schema !== "paperclip.prp.event.v1" || f.sourceKind !== "runner" || f.eventType !== row.eventType || f.runId !== request.runId || f.turnId !== request.turnId
|
||||
|| !settlementId(f.normalizedSessionId) || !settlementId(f.sourceInstanceId) || !Number.isSafeInteger(f.sourceSeq) || f.sourceSeq <= 0
|
||||
|| f.sourceEventId !== `${f.sourceInstanceId}:${request.runId}:${f.sourceSeq}` || !settlementTime(settlementDate(row.createdAt)) || !settlementTime(settlementDate(f.emittedAt))) throw invalid();
|
||||
return f;
|
||||
};
|
||||
const at = (seq: number) => { const found = rows.filter(r => r.seq === seq); if (found.length !== 1) throw invalid(); return get(found[0]!); };
|
||||
const origin = at(request.seq), delivery = at(delivered[0]!.seq), failure = at(failed[0]!.seq);
|
||||
const stream = (f: Record<string, any>) => f.normalizedSessionId === origin.normalizedSessionId && f.sourceInstanceId === origin.sourceInstanceId;
|
||||
if (![delivery, failure].every(stream) || origin.sourceSeq >= delivery.sourceSeq || delivery.sourceSeq >= failure.sourceSeq) throw invalid();
|
||||
const resolutions = rows.filter(r => ["runtime_request.resolved", "runtime_request.cancelled", "runtime_request.expired"].includes(r.eventType)
|
||||
&& settlementRecord(settlementRecord(settlementRecord(r.payload).prpEvent).payload).requestId === request.requestId);
|
||||
if (resolutions.length !== 1) throw invalid();
|
||||
const resolution = get(resolutions[0]!), resolved = settlementRecord(resolution.payload);
|
||||
if (!stream(resolution) || resolution.eventType !== "runtime_request.resolved" || resolved.requestKind !== "permission_approval"
|
||||
|| resolved.turnId !== request.turnId || resolved.action !== "decline" || resolution.sourceSeq <= origin.sourceSeq
|
||||
|| resolution.sourceSeq >= delivery.sourceSeq || resolutions[0]!.seq <= request.seq || resolutions[0]!.seq >= delivered[0]!.seq) throw invalid();
|
||||
const toolRows = rows.filter(r => r.eventType === "tool.execution.completed"
|
||||
&& settlementRecord(settlementRecord(settlementRecord(r.payload).prpEvent).payload).executionId === bootstrapReadExecutionId(request.toolCallId));
|
||||
if (toolRows.length !== 1) throw invalid();
|
||||
const tool = get(toolRows[0]!), toolPayload = settlementRecord(tool.payload);
|
||||
if (!stream(tool) || tool.sourceSeq <= failure.sourceSeq || toolRows[0]!.seq <= failed[0]!.seq
|
||||
|| toolPayload.schema !== "paperclip.tool.execution.v1" || toolPayload.status !== "failed" || toolPayload.operation !== "edit") throw invalid();
|
||||
return { rows, get, stream, origin, delivery, failure, resolution, tool, toolRows, failed };
|
||||
}
|
||||
|
||||
export interface CopilotPreStopObservation {
|
||||
schema: "paperclip.e2e.copilot-pre-stop-observation.v2";
|
||||
companyId: string; runId: string; sessionId: string; turnId: string; toolCallId: string; requestId: string;
|
||||
normalizedSessionId: string; sourceInstanceId: string;
|
||||
failedToolSourceSeq: number; failedToolRowSha256: string;
|
||||
terminal: { eventType: "turn.completed" | "turn.cancelled" | "turn.interrupted"; sourceSeq: number; rowSha256: string } | null;
|
||||
/** Fixture-process monotonic clock, never provider or database wall time. */
|
||||
apiReadCompletedMonotonicNs: string;
|
||||
/** Fixture correlation UUID; current mainline generates its own API intent. */
|
||||
cancellationRequestId: string;
|
||||
}
|
||||
const denialRowSha = (row: unknown) => `sha256:${createHash("sha256").update(canonicalJson(row)).digest("hex")}`;
|
||||
function readDeniedTerminal(proof: ReturnType<typeof readCopilotDeniedEdit>) {
|
||||
const { rows, get, stream, tool, failed } = proof;
|
||||
const invalid = () => new Error("Copilot denial lacks exact provider settlement");
|
||||
// Ignore legacy unwrapped log summaries, never use them as provider evidence.
|
||||
const terminalRows = rows.filter(r => ["turn.completed", "turn.cancelled", "turn.interrupted", "turn.failed"].includes(r.eventType)
|
||||
&& settlementRecord(r.payload).prpEvent !== undefined);
|
||||
if (terminalRows.length === 0) return null;
|
||||
if (terminalRows.length !== 1) throw invalid();
|
||||
const row = terminalRows[0]!, terminal = get(row), p = settlementRecord(terminal.payload);
|
||||
if (!stream(terminal) || terminal.sourceSeq <= tool.sourceSeq || row.seq <= failed[0]!.seq
|
||||
|| !["turn.completed", "turn.cancelled", "turn.interrupted"].includes(row.eventType)
|
||||
|| p.status !== row.eventType.slice(5) || p.error != null) throw invalid();
|
||||
return { row, terminal };
|
||||
}
|
||||
/** Call only on rows returned by the current operator API read. Retain this
|
||||
* receipt before dispatching Stop; historical rows cannot recreate that fact. */
|
||||
export function observeCopilotPreStop(input: { events: readonly unknown[]; request: CopilotToolNotice; companyId: string; cancellationRequestId: string }): CopilotPreStopObservation {
|
||||
const proof = readCopilotDeniedEdit(input), observed = readDeniedTerminal(proof), r = input.request;
|
||||
return { schema: "paperclip.e2e.copilot-pre-stop-observation.v2", companyId: input.companyId,
|
||||
runId: r.runId, sessionId: r.sessionId, turnId: r.turnId, toolCallId: r.toolCallId, requestId: r.requestId!,
|
||||
normalizedSessionId: proof.origin.normalizedSessionId, sourceInstanceId: proof.origin.sourceInstanceId,
|
||||
failedToolSourceSeq: proof.tool.sourceSeq, failedToolRowSha256: denialRowSha(proof.toolRows[0]),
|
||||
terminal: observed ? { eventType: observed.terminal.eventType, sourceSeq: observed.terminal.sourceSeq, rowSha256: denialRowSha(observed.row) } : null,
|
||||
apiReadCompletedMonotonicNs: process.hrtime.bigint().toString(), cancellationRequestId: input.cancellationRequestId };
|
||||
}
|
||||
|
||||
/** Denial closes one permission, not necessarily the provider prompt. Match the
|
||||
* single canonical terminal to the same source stream, then independently bind
|
||||
* the audited controller Stop. Database createdAt is transaction-start metadata, not a commit boundary. */
|
||||
export function readCopilotDenialSettlement(input: {
|
||||
events: readonly unknown[]; request: CopilotToolNotice; run: unknown; issue: unknown;
|
||||
preStop: CopilotPreStopObservation; stopDispatchMonotonicNs: string; stopAcknowledgement: CopilotStopAcknowledgement;
|
||||
}): CopilotDenialSettlement {
|
||||
const invalid = () => new Error("Copilot denial lacks exact provider settlement and audited run Stop");
|
||||
const { request, events } = input, run = settlementRecord(input.run), issue = settlementRecord(input.issue);
|
||||
const stop = settlementRecord(settlementRecord(run.resultJson).nativeCancellation);
|
||||
if (events.length > 20_000 || run.id !== request.runId || issue.id !== run.nativeIssueId
|
||||
|| !settlementId(issue.companyId) || run.companyId !== issue.companyId || issue.status !== "in_progress" || run.status !== "cancelled"
|
||||
|| stop.schema !== "paperclip.native-cancellation.v1" || stop.runId !== run.id || stop.companyId !== issue.companyId || stop.issueId !== issue.id
|
||||
|| stop.scope !== "run" || stop.dispatched !== true || stop.dispatchState !== "acknowledged" || stop.reasonCode !== "cancellation_run_only"
|
||||
|| !Array.isArray(stop.effects) || stop.effects.length !== 1 || stop.effects[0] !== "release_run_resources") throw invalid();
|
||||
try { assertCopilotStopAcknowledgement(input.stopAcknowledgement, run,
|
||||
{ companyId: issue.companyId, issueId: issue.id, runId: request.runId, callerUserId: "local-board" }, input.preStop.cancellationRequestId, input.stopDispatchMonotonicNs); }
|
||||
catch { throw invalid(); }
|
||||
const { rows, get, stream, origin, delivery, failure, resolution, tool, toolRows, failed } = readCopilotDeniedEdit({ events, request, companyId: issue.companyId });
|
||||
const observed = readDeniedTerminal({ rows, get, stream, origin, delivery, failure, resolution, tool, toolRows, failed });
|
||||
if (!observed) throw invalid();
|
||||
const { row, terminal } = observed;
|
||||
const result: CopilotDenialSettlement = {
|
||||
schema: "paperclip.e2e.copilot-denial-settlement.v4",
|
||||
branch: row.eventType === "turn.completed" ? "provider_completed_observed_before_stop" : "provider_cancelled_or_interrupted",
|
||||
providerCancellationTerminalObserved: row.eventType !== "turn.completed",
|
||||
runId: run.id, sessionId: request.sessionId, turnId: request.turnId, toolCallId: request.toolCallId, requestId: request.requestId!,
|
||||
preStop: input.preStop, stopDispatchMonotonicNs: input.stopDispatchMonotonicNs, stopAcknowledgement: input.stopAcknowledgement,
|
||||
providerTerminal: { eventType: row.eventType, rowSha256: denialRowSha(row), failedToolRowSha256: denialRowSha(toolRows[0]), normalizedSessionId: terminal.normalizedSessionId, sourceInstanceId: terminal.sourceInstanceId,
|
||||
requestSourceSeq: origin.sourceSeq, resolvedSourceSeq: resolution.sourceSeq, deliveredSourceSeq: delivery.sourceSeq, failedNoticeSourceSeq: failure.sourceSeq,
|
||||
failedToolSourceSeq: tool.sourceSeq, failedToolRowCreatedAtMs: settlementDate(toolRows[0]!.createdAt), sourceSeq: terminal.sourceSeq, emittedAtMs: settlementDate(terminal.emittedAt), rowCreatedAtMs: settlementDate(row.createdAt) },
|
||||
runStop: { companyId: issue.companyId, issueId: issue.id, scope: stop.scope, status: run.status, issueStatus: issue.status,
|
||||
intentId: stop.intentId, intentAuditId: stop.intentAuditId, acknowledgementAuditId: stop.acknowledgementAuditId,
|
||||
requestedAtMs: settlementDate(settlementRecord(settlementRecord(run.resultJson).startupCancellation).requestedAt),
|
||||
recordedAtMs: settlementDate(stop.recordedAt), acknowledgedAtMs: settlementDate(stop.acknowledgedAt), finishedAtMs: settlementDate(run.finishedAt) },
|
||||
};
|
||||
if (!validCopilotDenialSettlement(result)) throw invalid();
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface CopilotDenialSampleCursor {
|
||||
runId: string; turnId: string; normalizedSessionId: string; sourceInstanceId: string; sourceSeq: number;
|
||||
}
|
||||
/** Read boundary for a sample checkpoint, not a clock conversion. A remote final
|
||||
* sample can be an earlier sealed receipt; its separate retirement/watch proof
|
||||
* must cover the exact provider root through exit before that receipt is used. */
|
||||
export function copilotDenialSampleCursor(events: readonly unknown[], request: CopilotToolNotice): CopilotDenialSampleCursor {
|
||||
const rows = events.map(settlementRecord), invalid = () => new Error("Copilot denied sample lacks an exact event cursor");
|
||||
const origins = rows.filter(r => r.seq === request.seq);
|
||||
if (origins.length !== 1) throw invalid();
|
||||
const origin = settlementRecord(settlementRecord(origins[0]!.payload).prpEvent);
|
||||
const frames = rows.map(r => ({ row: r, f: settlementRecord(settlementRecord(r.payload).prpEvent) }))
|
||||
.filter(({ f }) => f.turnId === request.turnId);
|
||||
if (events.length > 20_000 || frames.length === 0 || origin.runId !== request.runId
|
||||
|| ![origin.normalizedSessionId, origin.sourceInstanceId].every(settlementId)) throw invalid();
|
||||
const seen = new Set<number>();
|
||||
for (const { row, f } of frames) {
|
||||
if (row.runId !== request.runId || f.runId !== request.runId || f.schema !== "paperclip.prp.event.v1" || f.sourceKind !== "runner"
|
||||
|| row.eventType !== f.eventType || f.normalizedSessionId !== origin.normalizedSessionId || f.sourceInstanceId !== origin.sourceInstanceId
|
||||
|| !Number.isSafeInteger(f.sourceSeq) || f.sourceSeq <= 0 || seen.has(f.sourceSeq)
|
||||
|| f.sourceEventId !== `${f.sourceInstanceId}:${f.runId}:${f.sourceSeq}`) throw invalid();
|
||||
seen.add(f.sourceSeq);
|
||||
}
|
||||
return { runId: request.runId, turnId: request.turnId, normalizedSessionId: origin.normalizedSessionId,
|
||||
sourceInstanceId: origin.sourceInstanceId, sourceSeq: Math.max(...seen) };
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { matchCopilotFixtureCommand } from "./copilot-protection-evidence.js";
|
||||
import { writeFileSync, unlinkSync, utimesSync } from "node:fs";
|
||||
import { spawn } from "node:child_process";
|
||||
import { readFile, mkdtemp, rm, writeFile, unlink, rename, mkdir, symlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCopilotToolEvidence } from "../../packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.js";
|
||||
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 { runnerMatrix, suiteDefinitionHash } from "./catalog.js";
|
||||
import { selectRunnerExecutions, parseRunnerSelectors } from "./selectors.js";
|
||||
|
||||
const fixture = JSON.parse(await readFile(new URL("../../packages/paperclip-runner/src/drivers/acpx/fixtures/copilot-tool-evidence.json", import.meta.url), "utf8"));
|
||||
function projected(name: string, target = "copilot-denied-nonce.txt") {
|
||||
const frames = JSON.parse(JSON.stringify(fixture[name]).replaceAll("copilot-denied-nonce.txt", target)), rows: any[] = []; let clock = 10;
|
||||
const projector = createCopilotToolEvidence({ sessionId: frames[0].params.sessionId, turnId: "turn", workingDirectory: "/fixture/workspace", active: () => true,
|
||||
emit: event => { validateAcpxRichEvent(event); rows.push({ seq: rows.length + 1, eventType: event.eventType, payload: { prpEvent: { schema: "paperclip.prp.event.v1", sourceKind: "runner", eventType: event.eventType, runId: "run", turnId: "turn", emittedAt: new Date(clock++).toISOString(), payload: event.payload } } }); } });
|
||||
for (const frame of frames) {
|
||||
if (frame.method === "session/update") projector.tool({ ...frame.params.update, type: "tool_call", tag: frame.params.update.sessionUpdate });
|
||||
else projector.permission({ raw: frame.params }, "permission", ["decline"])?.("reject_once");
|
||||
}
|
||||
return { rows, notices: readCopilotToolEvidence(rows, "run") };
|
||||
}
|
||||
describe("Copilot Product protection integration", () => {
|
||||
it("registers two explicit cases on both environments with honest terminal expectations", () => {
|
||||
const cells = runnerMatrix.filter(x => x.suite.id === "copilot-protection");
|
||||
expect(cells[0]!.suite.definitionMetadata).toMatchObject({ version: 20, titleCreation: "explicit-search-composer", taskBinding: "browser-creation-response-id", naturalSettlementObservationMs: 2000, denialTerminal: "correlated-provider-settlement-and-audited-run-stop",
|
||||
denialSettlementEvidence: "paperclip.e2e.copilot-denial-settlement.v4", activeTurnCancellation: "not-implied-by-completed-provider-turn" });
|
||||
expect(suiteDefinitionHash(cells[0]!.suite)).not.toBe(suiteDefinitionHash({ ...cells[0]!.suite, definitionMetadata: { version: 2 } }));
|
||||
expect(cells).toHaveLength(4); expect(new Set(cells.map(c => c.environment.id))).toEqual(new Set(["local", "daytona"]));
|
||||
expect(cells.every(c => c.profile.qualificationCandidate === "copilot" && c.task.expectedRunCount === 1)).toBe(true);
|
||||
expect(cells.find(c => c.task.id === "native-permission-deny-write")!.task.expectedTerminalState).toEqual({ issue: "in_progress", run: "cancelled" });
|
||||
expect(selectRunnerExecutions(parseRunnerSelectors(["--all"])).some(x => x.suite.id === "copilot-protection")).toBe(false);
|
||||
});
|
||||
it.each(["copilot-denied-nonce.txt", "pc-denied-ABC123/copilot-denied-nonce.txt"])("feeds native denied-edit wire through canonical persistence and exact target oracle: %s", target => {
|
||||
const { notices } = projected("deny-write", target);
|
||||
const request = notices.find(n => n.stage === "permission_requested")!, delivered = notices.find(n => n.stage === "permission_delivered")!, failed = notices.find(n => n.status === "failed")!;
|
||||
const e = { expected: copilotOrigin(request), requestId: "permission", expectedRelativePath: target,
|
||||
request: { ...request, requestId: "permission", method: "session/request_permission" as const, targetRelativePath: request.target!, offeredActions: request.declineOffered ? ["decline"] : [] },
|
||||
decision: { ...delivered, requestId: "permission", browserRequestId: "permission", action: delivered.outcome === "reject_once" ? "decline" : "accept" },
|
||||
deliveredDecision: { ...delivered, requestId: "permission", outcome: delivered.outcome! },
|
||||
toolResult: { ...failed, status: "failed" as const }, terminal: { runId: "run", turnId: "turn", observedAtMs: 50, status: "cancelled" as const }, settlement: { schema: "paperclip.e2e.copilot-denial-settlement.v4" as const, ...copilotOrigin(request), requestId: "permission",
|
||||
branch: "provider_cancelled_or_interrupted" as const, providerCancellationTerminalObserved: true,
|
||||
preStop: { schema: "paperclip.e2e.copilot-pre-stop-observation.v2" as const, ...copilotOrigin(request), requestId: "permission", companyId: "company", normalizedSessionId: "normalized", sourceInstanceId: "runner", failedToolSourceSeq: 5, failedToolRowSha256: `sha256:${"a".repeat(64)}`, terminal: null, apiReadCompletedMonotonicNs: "10", cancellationRequestId: "11111111-1111-4111-8111-111111111111" }, stopDispatchMonotonicNs: "20",
|
||||
stopAcknowledgement: { schema: "paperclip.e2e.copilot-stop-acknowledgement.v1" as const, companyId: "company", runId: "run", issueId: "issue", callerUserId: "local-board", fixtureCorrelationId: "11111111-1111-4111-8111-111111111111", dispatchMonotonicNs: "20", apiResponseObservedMonotonicNs: "21", intentId: "native-cancellation:11111111-1111-4111-8111-111111111111", intentAuditId: "intent-audit", acknowledgementAuditId: "ack-audit", responseMetadataSha256: `sha256:${"c".repeat(64)}` },
|
||||
providerTerminal: { rowSha256: `sha256:${"b".repeat(64)}`, failedToolRowSha256: `sha256:${"a".repeat(64)}`, eventType: "turn.cancelled" as const, normalizedSessionId: "normalized", sourceInstanceId: "runner", requestSourceSeq: 1, resolvedSourceSeq: 2, deliveredSourceSeq: 3, failedNoticeSourceSeq: 4, failedToolSourceSeq: 5, failedToolRowCreatedAtMs: 31, sourceSeq: 6, emittedAtMs: 50, rowCreatedAtMs: 51 },
|
||||
runStop: { companyId: "company", issueId: "issue", scope: "run" as const, status: "cancelled" as const, issueStatus: "in_progress" as const, intentId: "native-cancellation:11111111-1111-4111-8111-111111111111", intentAuditId: "intent-audit", acknowledgementAuditId: "ack-audit", requestedAtMs: 40, recordedAtMs: 41, acknowledgedAtMs: 52, finishedAtMs: 53 } },
|
||||
cleanup: { observedAtMs: 60, ownedProcessesRemaining: 0 }, nativeAttemptsForTarget: 1,
|
||||
fileObservations: (["before-request", "pending", "after-decision", "terminal", "after-cleanup"] as const).map((phase, index) => ({ phase, observedAtMs: [0, request.observedAtMs, 30, 50, 60][index]!, exists: false, providerCursor: index === 0 ? null : { runId: "run", turnId: "turn", normalizedSessionId: "normalized", sourceInstanceId: "runner", sourceSeq: [0, 1, 4, 6, 6][index]! } })), mutationObservation: { startedAtMs: 0, endedAtMs: 60, complete: true, targetMutationCount: 0 } };
|
||||
expect(request.target).toBe(target);
|
||||
expect(gradeCopilotDeniedWrite(e).passed).toBe(true);
|
||||
e.request.targetRelativePath = "foreign.txt"; expect(gradeCopilotDeniedWrite(e).passed).toBe(false);
|
||||
e.request.targetRelativePath = e.expectedRelativePath; e.settlement.runStop.acknowledgementAuditId = ""; expect(gradeCopilotDeniedWrite(e).passed).toBe(false);
|
||||
});
|
||||
it("feeds actual attached wire through canonical notices and rejects early terminal or missing linkage", () => {
|
||||
const { notices } = projected("attached-shell"); const call = notices.find(n => n.commandSha256)!;
|
||||
const started = notices.find(n => n.shellState === "started")!, result = notices.find(n => n.shellState === "completed")!;
|
||||
const e = { expected: copilotOrigin(call), nativeCall: { ...call, operation: call.operation!, mode: call.mode!, detach: call.detach!, commandSha256: call.commandSha256! }, expectedCommand: fixture["attached-shell"][0].params.update.rawInput.command, commandMatch: matchCopilotFixtureCommand(fixture["attached-shell"][0].params.update.rawInput.command, call.commandSha256!), expectedCommandSha256: call.commandSha256!, expectedShellId: started.shellId!,
|
||||
commandExit: { observedAtMs: result.observedAtMs - 1, code: 0, ownedProcessIdentityVerified: true, commandSha256: call.commandSha256! },
|
||||
nativeShellResult: { ...result, shellId: result.shellId!, commandToolCallId: result.commandToolCallId!, status: result.status!, exitCode: result.exitCode! },
|
||||
terminal: { observedAtMs: 50, runId: "run", turnId: "turn", status: "succeeded" as const }, cleanup: { observedAtMs: 60, ownedProcessesRemaining: 0 }, terminalMarkerMatches: true, afterCleanupMarkerMatches: true };
|
||||
expect(gradeCopilotAttachedSettlement(e).passed).toBe(true);
|
||||
e.terminal.observedAtMs = call.observedAtMs + 1; expect(gradeCopilotAttachedSettlement(e).passed).toBe(false);
|
||||
e.terminal.observedAtMs = 50; e.nativeShellResult.commandToolCallId = "foreign"; expect(gradeCopilotAttachedSettlement(e).passed).toBe(false);
|
||||
});
|
||||
it("rejects foreign, redacted, malformed and explicitly incomplete persisted notices", () => {
|
||||
for (const mutate of [
|
||||
(r: any) => { r.payload.prpEvent.runId = "other"; },
|
||||
(r: any) => { r.payload.prpEvent.payload.provenance.turnId = "old"; },
|
||||
(r: any) => { r.payload.prpEvent.payload.details.push({ name: "stage", value: "tool" }); },
|
||||
(r: any) => { r.payload.prpEvent.payload.details[0].value = "evidence_incomplete"; },
|
||||
(r: any) => { r.payload.prpEvent.payload.details[1].value = "[REDACTED]"; },
|
||||
]) { const { rows } = projected("attached-shell"); mutate(rows[0]); expect(() => readCopilotToolEvidence(rows, "run")).toThrow(); }
|
||||
expect(readCopilotToolEvidence([], "run")).toEqual([]); // Missing proof never fabricates an event.
|
||||
});
|
||||
it("isolates the denied parent from unrelated workspace startup churn and binds the prompt", async () => {
|
||||
const root = await mkdtemp("/tmp/pc-denial-isolated-");
|
||||
const fixture = await createDeniedTargetFixture(root, "copilot-denied-nonce.txt");
|
||||
try {
|
||||
const prompt = bindDeniedTargetPrompt(copilotProtectionCases[0].prompt("nonce"), "copilot-denied-nonce.txt", fixture.targetRelativePath);
|
||||
expect(prompt).toContain(`creating ${fixture.targetRelativePath} with DENIED-nonce`);
|
||||
expect(fixture.targetRelativePath).toMatch(/^pc-denied-[a-zA-Z0-9]+\/copilot-denied-nonce\.txt$/);
|
||||
await mkdir(join(root, "startup")); await writeFile(join(root, "startup", "runtime.json"), "{}");
|
||||
await writeFile(join(root, "transient"), "x"); await unlink(join(root, "transient"));
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
expect(fixture.watcher.finish()).toMatchObject({ complete: true, targetMutationCount: 0, reasons: [] });
|
||||
} finally { fixture.watcher.finish(); await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
it("retains a coverage gap when create/delete occurs before callbacks can arrive", async () => {
|
||||
const root = await mkdtemp("/tmp/pc-denial-gap-");
|
||||
// Make the pre-mutation version distinct even on filesystems that coalesce
|
||||
// directory timestamps within one clock tick. Assertions remain immediate.
|
||||
utimesSync(root, new Date(0), new Date(0));
|
||||
const watcher = watchDeniedTarget(root, "denied"), targetPath = join(root, "denied");
|
||||
try {
|
||||
writeFileSync(targetPath, "x"); unlinkSync(targetPath);
|
||||
const receipt = watcher.finish();
|
||||
expect(receipt).toMatchObject({ complete: false, targetMutationCount: 0 });
|
||||
expect(receipt.reasons).toContain("coverage-gap-parent-version-changed");
|
||||
expect(receipt.finalParent).not.toEqual(receipt.initialParent);
|
||||
expect(watcher.finish()).toBe(receipt);
|
||||
} finally { watcher.finish(); await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
it("refuses a preexisting target, symlink parent and ambiguous prompt", async () => {
|
||||
const root = await mkdtemp("/tmp/pc-denial-invalid-");
|
||||
try {
|
||||
await writeFile(join(root, "denied"), "present");
|
||||
expect(() => watchDeniedTarget(root, "denied")).toThrow(/initially be absent/);
|
||||
await symlink(root, join(root, "link"));
|
||||
expect(() => watchDeniedTarget(join(root, "link"), "absent")).toThrow(/real directory/);
|
||||
expect(() => watchDeniedTarget(root, "../denied")).toThrow(/Invalid/);
|
||||
expect(() => bindDeniedTargetPrompt("no target", "denied", "pc-denied-a/denied")).toThrow(/exact target once/);
|
||||
expect(() => bindDeniedTargetPrompt("denied and denied", "denied", "pc-denied-a/denied")).toThrow(/exact target once/);
|
||||
} finally { await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
it("observes a transient create/delete even when final stat is absent", async () => {
|
||||
const root = await mkdtemp("/tmp/pc-copilot-watch-"); const watcher = watchDeniedTarget(root, "denied");
|
||||
try { await writeFile(join(root, "denied"), "x"); await unlink(join(root, "denied")); await new Promise(r => setTimeout(r, 30)); const proof = watcher.finish(); expect(proof.targetMutationCount > 0 || !proof.complete).toBe(true); expect(await exists(join(root, "denied"))).toBe(false); }
|
||||
finally { watcher.finish(); await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
it("rejects replacement or disappearance of the watched parent directory", async () => {
|
||||
const base = await mkdtemp("/tmp/pc-copilot-parent-"); const root = join(base, "workspace"); await mkdir(root);
|
||||
const watcher = watchDeniedTarget(root, "denied");
|
||||
try { await rename(root, join(base, "old")); await mkdir(root); expect(watcher.finish().complete).toBe(false); expect(watcher.finish().reasons).toContain("parent-identity-changed"); }
|
||||
finally { watcher.finish(); await rm(base, { recursive: true, force: true }); }
|
||||
const gone = await mkdtemp("/tmp/pc-copilot-parent-"); const deleted = watchDeniedTarget(gone, "denied");
|
||||
await rm(gone, { recursive: true }); expect(deleted.finish().complete).toBe(false); expect(deleted.finish().reasons).toContain("parent-unavailable-at-finish");
|
||||
});
|
||||
it("binds cleanup to the exact per-turn runner PID/start/run, never a warm or reused process", () => {
|
||||
const startedAt = new Date(1_700_000_000_000).toISOString();
|
||||
const authority = { pid: 100, groupId: 100, startedAt, runId: "run-id" };
|
||||
const observed = { pid: 100, parent: 1, start: new Date(startedAt).toString() };
|
||||
const args = "/private/runner --run-id run-id --lifecycle-mode per_turn";
|
||||
expect(isPerTurnRunProcess(authority, observed, args)).toBe(true);
|
||||
for (const bad of [args.replace("per_turn", "warm"), args.replace("run-id run-id", "run-id other"), args + " --run-id run-id", "/server"]) expect(isPerTurnRunProcess(authority, observed, bad)).toBe(false);
|
||||
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("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 {
|
||||
const client = spawn("/bin/sh", ["-c", fixture.command], { stdio: "ignore" });
|
||||
const code = await new Promise<number | null>(resolve => client.once("exit", resolve)); const observedAtMs = Date.now(); const proof = fixture.snapshot();
|
||||
expect(code).toBe(0); expect(proof).toMatchObject({ connections: 1, failure: null, childGone: true, clientGone: true });
|
||||
expect(proof.commandExit?.code).toBe(0); expect(proof.commandExit!.observedAtMs).toBeLessThanOrEqual(observedAtMs);
|
||||
expect(await readFile(join(root, "marker"), "utf8")).toBe(fixture.marker);
|
||||
} finally { await fixture.close(); await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects remote protection before any API access without bootstrap and pre-teardown authority", async () => {
|
||||
const { runCopilotProtectionFlow } = await import("./copilot-protection-flow.js");
|
||||
let calls = 0;
|
||||
await expect(runCopilotProtectionFlow({ execution: { environment: { id: "daytona" }, profile: { qualificationCandidate: "copilot" }, task: { id: "native-permission-deny-write" } }, api: { get: async () => { calls++; } } } as any)).rejects.toThrow(/bootstrap and pre-teardown/);
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
import { readCopilotStopAcknowledgement, type CopilotStopAcknowledgement } from "./copilot-stop-acknowledgement.js";
|
||||
import { copilotPendingPermissionCard } from "./copilot-permission-card.js";
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { pollUntil, type RunnerApi } from "./api.js";
|
||||
import { collectRunEvents } from "./run-observations.js";
|
||||
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 { 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";
|
||||
import type { MatrixExecution } from "./types.js";
|
||||
type Row = Record<string, any>;
|
||||
type Check = { id: string; passed: boolean; detail: string };
|
||||
/** Each persistence barrier reloads durable rows. Stop cannot overtake the
|
||||
* failed edit, and a terminal sample cannot use a pre-terminal event cursor. */
|
||||
export async function settleCopilotDeniedRun(input: {
|
||||
api: Pick<RunnerApi, "post">; request: CopilotToolNotice; deadlineAt: number;
|
||||
load(): Promise<{ events: readonly unknown[]; run: Row; issue: Row; retired: boolean }>;
|
||||
afterDeniedEdit(): Promise<void>;
|
||||
retainPreStop(receipt: CopilotPreStopObservation): Promise<void>;
|
||||
retainStopAcknowledgement?(receipt: CopilotStopAcknowledgement): Promise<void>;
|
||||
afterSettlement(): Promise<void>;
|
||||
}) {
|
||||
const cancellationRequestId = randomUUID();
|
||||
const assertBeforeStop = (state: Awaited<ReturnType<typeof input.load>>) => {
|
||||
if (state.run.id !== input.request.runId || state.run.status !== "running"
|
||||
|| state.run.resultJson?.startupCancellation != null || state.run.resultJson?.nativeCancellation != null) {
|
||||
throw new Error("Copilot denial observed an earlier Stop or non-running run");
|
||||
}
|
||||
};
|
||||
let stopSent = false;
|
||||
const poll = (label: string, accept: (state: Awaited<ReturnType<typeof input.load>>) => boolean) => pollUntil({
|
||||
label, deadlineAt: input.deadlineAt, load: async () => {
|
||||
const state = await input.load();
|
||||
// pollUntil recognizes this definitive rejection before invoking readers.
|
||||
if (["failed", "timed_out"].includes(state.run.status)) throw new Error(`Stopped waiting for ${label}: Copilot provider run failed`);
|
||||
if (!stopSent) {
|
||||
try { assertBeforeStop(state); }
|
||||
catch { throw new Error(`Stopped waiting for ${label}: Copilot denial observed an earlier Stop or non-running run`); }
|
||||
}
|
||||
return state;
|
||||
}, accept, intervalMs: 200,
|
||||
});
|
||||
await poll("persisted correlated failed native edit", state => {
|
||||
readCopilotDeniedEdit({ events: state.events, request: input.request, companyId: state.issue.companyId }); return true;
|
||||
});
|
||||
await input.afterDeniedEdit();
|
||||
// This negative case does not qualify active-turn cancellation. Give natural
|
||||
// settlement a fixed observation window inside the unchanged case deadline.
|
||||
const observeUntil = Math.min(input.deadlineAt, Date.now() + 2000);
|
||||
let preStop: CopilotPreStopObservation;
|
||||
while (true) {
|
||||
const state = await input.load();
|
||||
if (["failed", "timed_out"].includes(state.run.status)) throw new Error("Copilot provider run failed before Stop");
|
||||
assertBeforeStop(state);
|
||||
preStop = observeCopilotPreStop({ events: state.events, request: input.request, companyId: state.issue.companyId, cancellationRequestId });
|
||||
if (preStop.terminal || Date.now() >= observeUntil) break;
|
||||
await new Promise(resolve => setTimeout(resolve, Math.min(200, observeUntil - Date.now())));
|
||||
}
|
||||
if (Date.now() >= input.deadlineAt) throw new Error("Copilot denial deadline reached before Stop");
|
||||
// Await the artifact write before dispatch. Database createdAt cannot supply
|
||||
// this causal boundary, and a later replay must never manufacture it.
|
||||
await input.retainPreStop(preStop);
|
||||
if (Date.now() >= input.deadlineAt) throw new Error("Copilot denial deadline reached before Stop");
|
||||
const dispatchState = await input.load();
|
||||
assertBeforeStop(dispatchState);
|
||||
if (Date.now() >= input.deadlineAt) throw new Error("Copilot denial deadline reached before Stop");
|
||||
const stopDispatchMonotonicNs = process.hrtime.bigint().toString();
|
||||
const stopped = await input.api.post<Row>(`/api/heartbeat-runs/${input.request.runId}/cancel`);
|
||||
const stopAcknowledgement = readCopilotStopAcknowledgement(stopped,
|
||||
{ companyId: preStop.companyId, issueId: dispatchState.issue.id, runId: input.request.runId, callerUserId: "local-board" }, cancellationRequestId, stopDispatchMonotonicNs);
|
||||
await input.retainStopAcknowledgement?.(stopAcknowledgement);
|
||||
stopSent = true;
|
||||
await poll("correlated provider settlement and retired run", state => {
|
||||
if (!state.retired) return false;
|
||||
readCopilotDenialSettlement({ ...state, request: input.request, preStop, stopDispatchMonotonicNs, stopAcknowledgement }); return true;
|
||||
});
|
||||
await input.afterSettlement();
|
||||
return readCopilotDenialSettlement({ ...await input.load(), request: input.request, preStop, stopDispatchMonotonicNs, stopAcknowledgement });
|
||||
}
|
||||
|
||||
export async function runCopilotProtectionFlow(input: {
|
||||
page: Page; api: RunnerApi; fixtures: LiveFixtureValues; execution: MatrixExecution; nonce: string; workspacePath: string; deadlineAt: number;
|
||||
remoteBootstrap?: CopilotRemoteBootstrap;
|
||||
registerBeforeEnvironmentTeardownAssertion?(callback: () => Promise<Check[]>): void;
|
||||
observe(issue: Row, runs: Row[]): void; capture(id: string, label: string, file: string): Promise<void>; evidence(name: string, data: unknown): Promise<void>;
|
||||
}) {
|
||||
const { page, api, fixtures, execution, nonce, workspacePath } = input;
|
||||
const remote = execution.environment.id === "daytona";
|
||||
if ((!remote && execution.environment.id !== "local") || execution.profile.qualificationCandidate !== "copilot") throw new Error("Copilot protection fixtures require an isolated candidate");
|
||||
if (remote && (!input.remoteBootstrap || !input.registerBeforeEnvironmentTeardownAssertion)) throw new Error("Remote Copilot protection requires bootstrap and pre-teardown evidence hooks");
|
||||
const deny = execution.task.id === "native-permission-deny-write";
|
||||
if (!deny && execution.task.id !== "attached-async-settlement") throw new Error("Unknown Copilot protection case");
|
||||
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 remoteFixture: CopilotRemoteFixture | undefined, baseline: CopilotRemoteSnapshot | undefined, sealed: CopilotRemoteSnapshot | undefined;
|
||||
let remoteCommand: { command: string; commandSha256: string } | undefined;
|
||||
const remoteMarker = `${randomBytes(24).toString("hex")}\n`;
|
||||
const remoteSnapshots: CopilotRemoteSnapshot[] = [];
|
||||
async function sealRemote() {
|
||||
if (!remoteFixture || !baseline) throw new Error("Remote Copilot evidence was never armed");
|
||||
sealed ??= await remoteFixture.finish();
|
||||
assertCopilotRemoteRetirement(sealed, baseline); processes = sealed.processes;
|
||||
return sealed;
|
||||
}
|
||||
const targetName = `copilot-denied-${nonce}.txt`;
|
||||
const localTarget = deny && !remote ? await createDeniedTargetFixture(workspacePath, targetName) : undefined;
|
||||
const target = localTarget?.targetRelativePath ?? targetName, targetPath = localTarget?.targetPath ?? join(workspacePath, target);
|
||||
const fileObservations: CopilotDeniedWriteEvidence["fileObservations"] = [];
|
||||
let deniedRequest: CopilotToolNotice | undefined;
|
||||
const sample = async (phase: CopilotDeniedWriteEvidence["fileObservations"][number]["phase"]) => {
|
||||
const providerCursor = phase === "before-request" ? null : copilotDenialSampleCursor(runEvents, deniedRequest!);
|
||||
if (remote) {
|
||||
if (!remoteFixture || !baseline) throw new Error("Remote denied target has no baseline");
|
||||
const snapshot = sealed ?? (phase === "before-request" ? baseline : await remoteFixture.snapshot(phase));
|
||||
remoteSnapshots.push(snapshot); fileObservations.push({ ...copilotRemoteDeniedSample(snapshot, baseline, target, phase), providerCursor });
|
||||
} else fileObservations.push({ phase, observedAtMs: Date.now(), exists: await exists(targetPath), providerCursor });
|
||||
};
|
||||
const watcher = localTarget?.watcher;
|
||||
const markerPath = join(workspacePath, `copilot-settlement-${nonce}.txt`);
|
||||
const command = deny || remote ? undefined : await createAttachedCommandFixture(markerPath);
|
||||
const exactCommand = () => remoteCommand ?? command;
|
||||
let watchReceipt: CopilotDeniedWriteEvidence["mutationObservation"] | undefined;
|
||||
const check = (id: string, passed: boolean, detail: string) => { checks.push({ id, passed, detail }); expect(passed, detail).toBe(true); };
|
||||
async function load() {
|
||||
if (issue.id) issue = await api.get<Row>(`/api/issues/${issue.id}`);
|
||||
const listed = await api.get<Row[]>(`/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100`);
|
||||
runs = await Promise.all(listed.map(r => api.get<Row>(`/api/heartbeat-runs/${r.id}`)));
|
||||
if (runs.length > 1) throw new Error("Copilot protection case dispatched an extra run");
|
||||
input.observe(issue, runs);
|
||||
runEvents = runs[0] ? await collectRunEvents<Row>((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);
|
||||
return { issue, runs, runEvents, notices, processes };
|
||||
}
|
||||
const wait = (label: string, accept: (state: Awaited<ReturnType<typeof load>>) => boolean) => pollUntil({ label, deadlineAt: input.deadlineAt, load, accept, intervalMs: 200,
|
||||
reject: state => state.runs.some(r => ["failed", "timed_out"].includes(r.status)) ? "Copilot provider run failed" : undefined });
|
||||
try {
|
||||
const agent = await api.get<Row>(`/api/agents/${fixtures.agent.id}`);
|
||||
await api.patch(`/api/agents/${fixtures.agent.id}`, { adapterConfig: { ...agent.adapterConfig, acpxPermissionMode: deny ? "approve-reads" : "approve-all", timeoutSec: 120, lifecycleMode: "per_turn" } });
|
||||
check("per-turn-process-lifecycle", (await api.get<Row>(`/api/agents/${fixtures.agent.id}`)).adapterConfig?.lifecycleMode === "per_turn", "This case explicitly requires a per-turn runner process, never a retained warm daemon");
|
||||
const project = await api.post<Row>(`/api/companies/${fixtures.company.id}/projects`, {
|
||||
name: `Copilot protection ${nonce}`, executionWorkspacePolicy: { enabled: true, defaultMode: "shared_workspace", sharedWorkspaceConcurrency: "serialize", allowIssueOverride: false, environmentId: fixtures.environment.id, workspaceStrategy: { type: "project_primary" } },
|
||||
workspace: { name: "Primary", sourceType: "local_path", cwd: workspacePath, isPrimary: true },
|
||||
});
|
||||
if (deny && !remote) { await sample("before-request"); check("target-initially-absent", !fileObservations[0]!.exists, "The exact isolated target is absent before dispatch"); }
|
||||
const prompt = remote ? input.remoteBootstrap!.prompt(nonce) : `${localTarget ? bindDeniedTargetPrompt(execution.task.buildPrompt(nonce), targetName, target) : execution.task.buildPrompt(nonce)}${command ? `\nThe exact supplied command is:\n${command.command}\nDo not inspect or modify fixture code, fabricate its marker, or launch a substitute command.` : ""}`;
|
||||
const createdTask = await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(nonce), prompt, workMode: "standard", projectName: project.name, requireExplicitTitle: true });
|
||||
const found = await pollUntil({ label: "browser-created Copilot protection task", deadlineAt: input.deadlineAt, load: async () => (await api.get<Row[]>(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(r => r.id === createdTask.issueId), accept: Boolean });
|
||||
if (!found) throw new Error("Browser-created task was not found"); issue = found;
|
||||
await page.goto(`/${fixtures.company.issuePrefix}/issues/${issue.identifier ?? issue.id}`);
|
||||
await prepareCopilotContext({
|
||||
remoteSetup: remote ? async () => {
|
||||
await wait("exact remote bootstrap run", state => state.runs.length === 1 && state.runs[0]?.status === "running");
|
||||
const bound = await input.remoteBootstrap!.bindAndRelease({ issueId: issue.id, runId: runs[0]!.id,
|
||||
targets: [deny ? target : `copilot-settlement-${nonce}.txt`],
|
||||
actionPrompt: async fixture => {
|
||||
remoteFixture = fixture;
|
||||
const prepared = await prepareCopilotRemoteAction({ fixture, companyId: fixtures.company.id, environmentId: fixtures.environment.id, runId: runs[0]!.id,
|
||||
target: deny ? target : `copilot-settlement-${nonce}.txt`, prompt: execution.task.buildPrompt(nonce), ...(deny ? {} : { markerText: remoteMarker }) });
|
||||
baseline = prepared.baseline; remoteCommand = prepared.command;
|
||||
if (deny) { await sample("before-request"); check("target-initially-absent", !fileObservations[0]!.exists, "The actual remote target is absent before action publication"); }
|
||||
await input.evidence("copilot-remote-baseline.json", baseline);
|
||||
return prepared.prompt;
|
||||
} });
|
||||
if (bound !== remoteFixture) throw new Error("Remote Copilot fixture changed during publication");
|
||||
input.registerBeforeEnvironmentTeardownAssertion!(async () => {
|
||||
try {
|
||||
const receipt = await sealRemote();
|
||||
if (deny) {
|
||||
if (copilotRemoteDeniedSample(receipt, baseline!, target, "after-cleanup").exists) throw new Error("Remote denied target exists after retirement");
|
||||
}
|
||||
else {
|
||||
if (!await readCopilotRemoteMarkerAfterRetirement(remoteFixture!, baseline!, `copilot-settlement-${nonce}.txt`, remoteMarker)) throw new Error("Remote sealed marker changed or disappeared");
|
||||
}
|
||||
await input.evidence("copilot-remote-pre-teardown.json", receipt);
|
||||
return [{ id: "remote-sealed-retirement", passed: true, detail: "Exact remote run root and descendants retired; retained pre-deletion filesystem receipt validated" }];
|
||||
} finally { await remoteFixture!.close(); }
|
||||
});
|
||||
} : undefined,
|
||||
approveContext: async () => {
|
||||
if (deny) await approveCopilotContextThroughUi({ page, companyId: fixtures.company.id, deadlineAt: input.deadlineAt,
|
||||
load: async () => { const state = await load(); return { run: state.runs[0] ?? {}, events: state.runEvents }; }, evidence: input.evidence });
|
||||
},
|
||||
});
|
||||
if (deny) {
|
||||
await wait("exact native write permission", s => s.notices.some(n => n.stage === "permission_requested" && n.operation === "edit" && n.target === target && n.declineOffered && s.runEvents.some(r => r.eventType === "runtime_request.created" && r.payload?.prpEvent?.payload?.request?.requestId === n.requestId)));
|
||||
const request = notices.find(n => n.stage === "permission_requested" && n.operation === "edit" && n.target === target)!;
|
||||
deniedRequest = request;
|
||||
const pending = runEvents.filter(r => r.eventType === "runtime_request.created" && r.payload?.prpEvent?.payload?.request?.requestId === request.requestId).map(r => r.payload.prpEvent.payload.request);
|
||||
check("one-bound-permission", pending.length === 1 && pending[0].requestKind === "permission_approval" && pending[0].origin?.method === "session/request_permission", "The notice maps to the exact durable native permission card");
|
||||
const retired = new Set(runEvents.filter(r => ["runtime_request.resolved", "runtime_request.cancelled", "runtime_request.expired"].includes(r.eventType)).map(r => r.payload?.prpEvent?.payload?.requestId));
|
||||
const activeRequests = runEvents.filter(r => r.eventType === "runtime_request.created" && r.payload?.prpEvent?.payload?.request && !retired.has(r.payload.prpEvent.payload.request.requestId));
|
||||
check("only-one-pending-native-request", activeRequests.length === 1, "Only the exact denied edit is awaiting a decision");
|
||||
await sample("pending"); await page.reload();
|
||||
const card = copilotPendingPermissionCard(page, target);
|
||||
await expect(card).toHaveCount(1); await expect(card).toContainText(target); await input.capture("permission-pending", "Copilot native write awaiting denial", "permission-pending.png");
|
||||
const url = `/api/heartbeat-runs/${request.runId}/runtime-requests/${encodeURIComponent(request.requestId!)}/resolve`;
|
||||
const sent = page.waitForRequest(r => new URL(r.url()).pathname === url && r.method() === "POST");
|
||||
const clickedAtMs = Date.now(); await card.getByRole("button", { name: "Deny", exact: true }).click();
|
||||
const posted = (await sent).postDataJSON();
|
||||
check("browser-exact-denial", posted.turnId === request.turnId && posted.requestKind === "permission_approval" && posted.resolution?.action === "decline", "Browser submitted denial for the exact run/request/turn");
|
||||
const settlement = await settleCopilotDeniedRun({ api, request, deadlineAt: input.deadlineAt,
|
||||
load: async () => {
|
||||
const state = await load();
|
||||
return { events: state.runEvents, run: state.runs[0]!, issue: state.issue,
|
||||
retired: remote || (state.processes.captured && state.processes.live.length === 0) };
|
||||
},
|
||||
afterDeniedEdit: () => sample("after-decision"),
|
||||
retainPreStop: receipt => input.evidence("copilot-pre-stop-observation.json", receipt),
|
||||
retainStopAcknowledgement: receipt => input.evidence("copilot-stop-acknowledgement.json", receipt),
|
||||
afterSettlement: async () => {
|
||||
if (remote) await sealRemote();
|
||||
await sample("terminal"); await new Promise(resolve => setTimeout(resolve, 100)); await load(); await sample("after-cleanup");
|
||||
},
|
||||
});
|
||||
watchReceipt = remote ? { startedAtMs: baseline!.observedAtMs, endedAtMs: sealed!.observedAtMs, complete: sealed!.watcher.complete, targetMutationCount: sealed!.watcher.targetMutationCount } : watcher!.finish();
|
||||
const toolResult = notices.find(n => n.stage === "tool" && n.toolCallId === request.toolCallId && n.status === "failed")!;
|
||||
await input.evidence("copilot-denial-settlement.json", settlement);
|
||||
check("correlated-provider-settlement", true, `Exact provider settlement (${settlement.branch}) and audited run Stop; completed does not cover active-turn cancellation`);
|
||||
const terminalAt = settlement.providerTerminal.emittedAtMs;
|
||||
const evidence: CopilotDeniedWriteEvidence = {
|
||||
expected: copilotOrigin(request), requestId: request.requestId!, expectedRelativePath: target,
|
||||
request: { ...request, requestId: request.requestId!, targetRelativePath: request.target!, method: "session/request_permission", offeredActions: request.declineOffered ? ["decline"] : [] },
|
||||
decision: { ...request, observedAtMs: clickedAtMs, requestId: request.requestId!, browserRequestId: request.requestId!, action: "decline" },
|
||||
deliveredDecision: (() => { const n = notices.find(n => n.stage === "permission_delivered" && n.requestId === request.requestId && n.outcome === "reject_once"); return n ? { ...n, requestId: n.requestId!, outcome: n.outcome! } : null; })(),
|
||||
toolResult: { ...toolResult, status: "failed" },
|
||||
terminal: { runId: settlement.runId, turnId: settlement.turnId, observedAtMs: terminalAt, status: runs[0]!.status },
|
||||
settlement,
|
||||
cleanup: { observedAtMs: fileObservations.at(-1)!.observedAtMs, ownedProcessesRemaining: processes.live.length }, fileObservations, mutationObservation: watchReceipt,
|
||||
nativeAttemptsForTarget: countCopilotEditOriginsForTarget(notices, target),
|
||||
};
|
||||
await input.evidence("copilot-denial-proof.json", { evidence, processes, notices });
|
||||
const grade = gradeCopilotDeniedWrite(evidence); check("denial-without-side-effects", grade.passed, grade.failures.join(", ") || "Exact browser denial, explicit cancellation and absence through process cleanup");
|
||||
check("negative-task-unfinished", issue.status === "in_progress", "The negative test does not claim the task is done");
|
||||
check("no-extra-native-operation", countCopilotToolOrigins(copilotActionNotices(notices, notices.find(n => n.stage === "tool" && n.toolCallId === request.toolCallId)!, remoteFixture ? { actionFile: remoteFixture.actionFile, events: runEvents } : undefined, runEvents)) === 1, "No alternate native edit, command or delegated operation is permitted");
|
||||
} else {
|
||||
await wait("attached command and task settlement", s => s.issue.status === "done" && s.runs[0]?.status === "succeeded" && (remote || (s.processes.captured && s.processes.live.length === 0)));
|
||||
// Preserve independent local proof before any command matcher can abort.
|
||||
const { external: localExternal, markerMatches: localMarkerMatches, afterCleanupMarkerMatches: localAfterCleanupMarkerMatches, matched } =
|
||||
await observeCopilotFixtureCommand(notices, exactCommand()!.command, command ? { fixture: command, markerPath } : undefined);
|
||||
await input.evidence("copilot-attached-command-observation.json", { notices, processes, external: localExternal,
|
||||
canonicalCommandSha256: exactCommand()!.commandSha256, commandMatch: matched?.match ?? null,
|
||||
markerMatches: localMarkerMatches, afterCleanupMarkerMatches: localAfterCleanupMarkerMatches });
|
||||
const call = matched?.call;
|
||||
check("single-exact-command", Boolean(matched), "Exactly one native execution matches the fixture command with at most eight leading ASCII SPACE/TAB bytes");
|
||||
const semantic = readCopilotSemanticCompletion(runEvents, { companyId: fixtures.company.id, runId: call!.runId, turnId: call!.turnId,
|
||||
nativeSessionId: call!.sessionId, command: call!, summary: execution.task.buildVisibleMarker(nonce), requireContextRead: true });
|
||||
await input.evidence("copilot-semantic-completion.json", semantic);
|
||||
check("accepted-canonical-completion", true, "One exact native finish receipt matches the proposed and control-plane accepted result; transport success alone is insufficient");
|
||||
check("no-extra-native-operation", onlyCopilotAttachedOperations(copilotActionNotices(notices, call!, remoteFixture ? { actionFile: remoteFixture.actionFile, events: runEvents } : undefined, runEvents), call!, semantic), "Only attested context/setup reads, the exact attached command/result, and one authoritatively correlated accepted finish are allowed");
|
||||
const started = notices.find(n => n.toolCallId === call!.toolCallId && n.shellState === "started");
|
||||
const result = notices.find(n => n.commandToolCallId === call!.toolCallId && n.shellState === "completed");
|
||||
const terminal = runEvents.find(r => r.eventType === "turn.completed" && r.payload?.prpEvent?.turnId === call!.turnId)?.payload.prpEvent;
|
||||
if (remote) await sealRemote();
|
||||
const remoteAttached = remote ? assertCopilotRemoteAttached(sealed!, baseline!, terminal ? Date.parse(terminal.emittedAt) : NaN) : undefined;
|
||||
const external = remoteAttached ? { ...remoteAttached, childGone: true, clientGone: true,
|
||||
commandExit: { ...remoteAttached.commandExit!, ownedProcessIdentityVerified: true, commandSha256: exactCommand()!.commandSha256 } } : localExternal!;
|
||||
check("trusted-command-exit", !external.failure && external.connections === 1 && external.childGone && external.clientGone, "Fixed controller-owned child exited and its native client is gone");
|
||||
const markerMatches = remote ? sealed!.targets[`copilot-settlement-${nonce}.txt`]?.sha256 === `sha256:${createHash("sha256").update(remoteMarker).digest("hex")}` : localMarkerMatches!;
|
||||
check("native-client-before-terminal", Boolean(terminal) && external.clientExitedAtMs !== null && external.clientExitedAtMs < Date.parse(terminal.emittedAt), "Independent PID/start observation confirms native client retirement before turn completion");
|
||||
check("marker-before-terminal", Boolean(terminal) && external.markerWrittenAtMs !== null && external.markerWrittenAtMs < Date.parse(terminal.emittedAt), "The independent fixture wrote its undisclosed marker before turn completion");
|
||||
const afterCleanupMarkerMatches = remote ? await readCopilotRemoteMarkerAfterRetirement(remoteFixture!, baseline!, `copilot-settlement-${nonce}.txt`, remoteMarker) : localAfterCleanupMarkerMatches!;
|
||||
const grade = gradeCopilotAttachedSettlement({ expected: copilotOrigin(call!), nativeCall: call ? { ...call, operation: call.operation!, mode: call.mode!, detach: call.detach!, commandSha256: call.commandSha256! } : null,
|
||||
expectedCommand: exactCommand()!.command, expectedCommandSha256: exactCommand()!.commandSha256, commandMatch: matched!.match, commandExit: external.commandExit,
|
||||
expectedShellId: started?.shellId ?? "", nativeShellResult: result ? { ...result, shellId: result.shellId!, commandToolCallId: result.commandToolCallId!, status: result.status!, exitCode: result.exitCode! } : null,
|
||||
terminal: terminal ? { observedAtMs: Date.parse(terminal.emittedAt), runId: terminal.runId, turnId: terminal.turnId, status: "succeeded" } : null,
|
||||
cleanup: { observedAtMs: remote ? sealed!.observedAtMs : Date.now(), ownedProcessesRemaining: processes.live.length }, terminalMarkerMatches: markerMatches, afterCleanupMarkerMatches });
|
||||
await input.evidence("copilot-attached-proof.json", { external, processes, notices, grade, commandSha256: exactCommand()!.commandSha256, commandMatch: matched!.match, markerMatches, afterCleanupMarkerMatches });
|
||||
check("attached-settlement-before-terminal", grade.passed, grade.failures.join(", ") || "Owned finite process and native shell settled before the actual turn terminal");
|
||||
}
|
||||
await load(); check("one-native-run", runs.length === 1 && runs[0]!.runtimeMode === "native", "Exactly one native run was accounted");
|
||||
const comments = await api.get<Row[]>(`/api/issues/${issue.id}/comments`), interactions = await api.get<Row[]>(`/api/issues/${issue.id}/interactions`);
|
||||
await input.evidence("api-state.json", { issue, run: runs[0], runs, comments, interactions, checks, runEvents, runEventsByRun: [{ runId: runs[0]!.id, events: runEvents }] });
|
||||
await page.reload();
|
||||
const label = deny ? "In Progress" : "Done";
|
||||
await expect(page.getByTestId("issue-detail-header").getByRole("button", { name: `Change status (current: ${label})`, exact: true })).toBeVisible();
|
||||
if (!deny) check("exact-completion-marker", comments.filter(c => c.authorAgentId === fixtures.agent.id && c.body?.trim() === execution.task.buildVisibleMarker(nonce)).length === 1, "The successful attached task has exactly one terminal marker");
|
||||
await input.capture("final-state", "Copilot protection outcome", "final-state.png");
|
||||
return { issue, runs, checks };
|
||||
} finally {
|
||||
watchReceipt ??= watcher?.finish();
|
||||
try { await command?.close(); }
|
||||
finally { await input.evidence("copilot-protection-checks.json", { issue, runs, checks, fileObservations, watchReceipt, processes, remoteSnapshots, sealed }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { applyMainlineStopMetadata, stopAcknowledgementForTest } from "./copilot-stop-test-fixtures.js";
|
||||
import { readCopilotToolEvidence } from "./copilot-evidence.js";
|
||||
import { observeCopilotPreStop, copilotDenialSampleCursor, readCopilotDenialSettlement } from "./copilot-protection-evidence.js";
|
||||
import { settleCopilotDeniedRun } from "./copilot-protection-flow.js";
|
||||
|
||||
// Sanitized source order from the failed Daytona attempt. Its .900 createdAt
|
||||
// is transaction time, not commit evidence. Pre-Stop receipts below are synthetic
|
||||
// test observations; the historical paid attempt did not capture one.
|
||||
const date = (ms: number) => new Date(Date.UTC(2026, 8, 30, 15, 13, 7, ms)).toISOString();
|
||||
function fixture(eventType = "turn.completed") {
|
||||
const frame = (seq: number, type: string, payload: unknown, persisted = 305, emitted = 129): any => ({
|
||||
seq: seq + 40, companyId: "company", runId: "run", eventType: type, createdAt: date(persisted),
|
||||
payload: { prpEvent: { schema: "paperclip.prp.event.v1", sourceKind: "runner", runId: "run", turnId: "turn",
|
||||
normalizedSessionId: "normalized-session", sourceInstanceId: "runner-instance", sourceSeq: seq,
|
||||
sourceEventId: `runner-instance:run:${seq}`, eventType: type, emittedAt: date(emitted), payload } },
|
||||
});
|
||||
const notice = (seq: number, stage: string, details: Record<string, string>) => frame(seq, "provider.notice.recorded", {
|
||||
schema: "paperclip.provider.notice.v1", category: "copilot_tool_evidence_v1", scope: "turn",
|
||||
provenance: { sessionId: "native-session", turnId: "turn", eventType: stage, method: stage === "tool" ? "session/update" : "session/request_permission" },
|
||||
details: Object.entries({ stage, toolCallId: "denied-edit", operation: "edit", ...details }).map(([name, value]) => ({ name, value })),
|
||||
});
|
||||
const events = [notice(111, "permission_requested", { requestId: "request", target: "copilot-denied-nonce.txt", declineOffered: "true" }),
|
||||
notice(113, "permission_delivered", { requestId: "request", outcome: "reject_once" }), notice(114, "tool", { status: "failed" }),
|
||||
frame(115, "tool.execution.completed", { schema: "paperclip.tool.execution.v1", executionId: "denied-edit", operation: "edit", status: "failed" }, 317),
|
||||
frame(121, eventType, { status: eventType.slice(5), error: null }, 900, 887),
|
||||
frame(112, "runtime_request.resolved", { requestId: "request", turnId: "turn", requestKind: "permission_approval", action: "decline" })];
|
||||
const run: any = { id: "run", companyId: "company", nativeIssueId: "issue", status: "cancelled", finishedAt: date(910),
|
||||
resultJson: { startupCancellation: { requestedAt: date(892), cancellationRequestId: "11111111-1111-4111-8111-111111111111" }, nativeCancellation: {
|
||||
schema: "paperclip.native-cancellation.v1", companyId: "company", runId: "run", issueId: "issue", scope: "run", dispatched: true,
|
||||
dispatchState: "acknowledged", reasonCode: "cancellation_run_only", effects: ["release_run_resources"], intentId: "native-cancellation:11111111-1111-4111-8111-111111111111",
|
||||
intentAuditId: "intent-audit", acknowledgementAuditId: "ack-audit", recordedAt: date(901), acknowledgedAt: date(909),
|
||||
} } };
|
||||
applyMainlineStopMetadata(run);
|
||||
const request = readCopilotToolEvidence(events, "run")[0]!;
|
||||
const preStop = observeCopilotPreStop({ events, request, companyId: "company", cancellationRequestId: "11111111-1111-4111-8111-111111111111" });
|
||||
const stopDispatchMonotonicNs = process.hrtime.bigint().toString();
|
||||
const stopAcknowledgement = stopAcknowledgementForTest(run, preStop.cancellationRequestId, stopDispatchMonotonicNs);
|
||||
return { events, request, run, issue: { id: "issue", companyId: "company", status: "in_progress" }, preStop, stopDispatchMonotonicNs, stopAcknowledgement };
|
||||
|
||||
}
|
||||
const terminal = (f: ReturnType<typeof fixture>) => f.events[4]!.payload.prpEvent;
|
||||
const liveRun = (f: ReturnType<typeof fixture>) => ({ ...f.run, status: "running", resultJson: {} });
|
||||
function stopPost(f: ReturnType<typeof fixture>, callback = () => {}) {
|
||||
return vi.fn(async (_path: string) => {
|
||||
callback();
|
||||
return f.run;
|
||||
});
|
||||
}
|
||||
|
||||
describe("Copilot denial provider settlement and separate audited run Stop", () => {
|
||||
it("accepts a synthetic pre-Stop API observation without claiming provider cancellation", () => {
|
||||
const f = fixture();
|
||||
expect(readCopilotDenialSettlement(f)).toMatchObject({ branch: "provider_completed_observed_before_stop", providerCancellationTerminalObserved: false,
|
||||
providerTerminal: { sourceSeq: 121, failedToolSourceSeq: 115, rowCreatedAtMs: Date.parse(date(900)) }, runStop: { status: "cancelled" } });
|
||||
// Native/provider clock skew is irrelevant to the operator causal boundary.
|
||||
terminal(f).emittedAt = date(999);
|
||||
f.preStop = observeCopilotPreStop({ ...f, companyId: "company", cancellationRequestId: "11111111-1111-4111-8111-111111111111" }); f.stopDispatchMonotonicNs = process.hrtime.bigint().toString();
|
||||
f.stopAcknowledgement = stopAcknowledgementForTest(f.run, f.preStop.cancellationRequestId, f.stopDispatchMonotonicNs);
|
||||
expect(readCopilotDenialSettlement(f).branch).toBe("provider_completed_observed_before_stop");
|
||||
});
|
||||
it.each(["turn.cancelled", "turn.interrupted"])("retains the distinct observed %s branch", type => {
|
||||
expect(readCopilotDenialSettlement(fixture(type))).toMatchObject({ branch: "provider_cancelled_or_interrupted", providerCancellationTerminalObserved: true });
|
||||
});
|
||||
it("cannot use a legacy summary or cancelled run as provider evidence", () => {
|
||||
const f = fixture(); f.events.splice(4, 1); f.events.push({ eventType: "turn.completed", payload: {} });
|
||||
expect(() => readCopilotDenialSettlement(f)).toThrow(/settlement/);
|
||||
});
|
||||
it.each(["equal", "later"])("accepts observed pre-Stop completion with %s transaction time, without ordering it against ack", kind => {
|
||||
const f = fixture(); f.events[4]!.createdAt = date(kind === "equal" ? 909 : 910);
|
||||
f.preStop = observeCopilotPreStop({ ...f, companyId: "company", cancellationRequestId: "11111111-1111-4111-8111-111111111111" }); f.stopDispatchMonotonicNs = process.hrtime.bigint().toString();
|
||||
f.stopAcknowledgement = stopAcknowledgementForTest(f.run, f.preStop.cancellationRequestId, f.stopDispatchMonotonicNs);
|
||||
expect(readCopilotDenialSettlement(f).branch).toBe("provider_completed_observed_before_stop");
|
||||
});
|
||||
it("rejects late normal completion even when its transaction began before Stop ack", () => {
|
||||
const f = fixture(); f.preStop.terminal = null; f.events[4]!.createdAt = date(800);
|
||||
expect(() => readCopilotDenialSettlement(f)).toThrow(/settlement/);
|
||||
});
|
||||
it.each(["missing", "foreign", "hash", "sequence", "equal-clock", "reverse-clock", "non-string-clock"])("rejects %s pre-dispatch observation", kind => {
|
||||
const f = fixture();
|
||||
if (kind === "missing") (f as any).preStop = null;
|
||||
if (kind === "foreign") f.preStop.runId = "foreign";
|
||||
if (kind === "hash") f.preStop.terminal!.rowSha256 = `sha256:${"a".repeat(64)}`;
|
||||
if (kind === "sequence") f.preStop.terminal!.sourceSeq++;
|
||||
if (kind === "equal-clock") f.stopDispatchMonotonicNs = f.preStop.apiReadCompletedMonotonicNs;
|
||||
if (kind === "reverse-clock") f.stopDispatchMonotonicNs = "1";
|
||||
if (kind === "non-string-clock") (f.preStop as any).apiReadCompletedMonotonicNs = ["1"];
|
||||
expect(() => readCopilotDenialSettlement(f)).toThrow(/settlement/);
|
||||
});
|
||||
it.each(["runId", "turnId", "normalizedSessionId", "sourceInstanceId", "eventType", "sourceEventId"])("rejects terminal %s mismatch", field => {
|
||||
const f = fixture(); terminal(f)[field] = "foreign";
|
||||
expect(() => readCopilotDenialSettlement(f)).toThrow(/settlement/);
|
||||
});
|
||||
it.each(["missing", "duplicate", "failed", "before-tool", "wrong-status", "error"])("rejects %s terminal", kind => {
|
||||
const f = fixture();
|
||||
if (kind === "missing") f.events.splice(4, 1);
|
||||
if (kind === "duplicate") f.events.push(structuredClone(f.events[4]));
|
||||
if (kind === "failed") { f.events[4]!.eventType = terminal(f).eventType = "turn.failed"; terminal(f).payload.status = "failed"; }
|
||||
if (kind === "before-tool") { terminal(f).sourceSeq = 114; terminal(f).sourceEventId = "runner-instance:run:114"; }
|
||||
if (kind === "wrong-status") terminal(f).payload.status = "cancelled";
|
||||
if (kind === "error") terminal(f).payload.error = { code: "failed" };
|
||||
expect(() => readCopilotDenialSettlement(f)).toThrow(/settlement/);
|
||||
});
|
||||
it.each(["scope", "runId", "companyId", "issueId", "intentId", "intentAuditId", "acknowledgementAuditId", "acknowledgedAt", "recordedAt", "schema", "dispatchState", "dispatched", "reasonCode", "effects"])("rejects malformed or unbound Stop %s", field => {
|
||||
const f = fixture(); f.run.resultJson.nativeCancellation[field] = null;
|
||||
expect(() => readCopilotDenialSettlement(f)).toThrow(/settlement/);
|
||||
});
|
||||
it.each(["missing-stop", "pending", "wrong-scope", "same-audit", "wrong-run-status", "issue-done", "ack-before-intent"])("rejects %s controller state", kind => {
|
||||
const f = fixture(), c = f.run.resultJson.nativeCancellation;
|
||||
if (kind === "missing-stop") delete f.run.resultJson.nativeCancellation;
|
||||
if (kind === "pending") c.dispatchState = "pending";
|
||||
if (kind === "wrong-scope") c.scope = "turn";
|
||||
if (kind === "same-audit") c.acknowledgementAuditId = c.intentAuditId;
|
||||
if (kind === "wrong-run-status") f.run.status = "succeeded";
|
||||
if (kind === "issue-done") f.issue.status = "done";
|
||||
if (kind === "ack-before-intent") c.recordedAt = date(911);
|
||||
expect(() => readCopilotDenialSettlement(f)).toThrow(/settlement/);
|
||||
});
|
||||
it.each(["native-session", "normalized-session", "failed-tool", "delivery", "duplicate-tool", "tool-order"])("rejects %s corruption", kind => {
|
||||
const f = fixture();
|
||||
if (kind === "native-session") f.events[2]!.payload.prpEvent.payload.provenance.sessionId = "foreign";
|
||||
if (kind === "normalized-session") f.events[2]!.payload.prpEvent.normalizedSessionId = "foreign";
|
||||
if (kind === "failed-tool") f.events[3]!.payload.prpEvent.payload.status = "completed";
|
||||
if (kind === "delivery") f.events[1]!.payload.prpEvent.payload.details.find((d: any) => d.name === "outcome").value = "allow_once";
|
||||
if (kind === "duplicate-tool") f.events.push(structuredClone(f.events[3]));
|
||||
if (kind === "tool-order") f.events[3]!.payload.prpEvent.sourceSeq = 122;
|
||||
expect(() => readCopilotDenialSettlement(f)).toThrow();
|
||||
});
|
||||
it.each(["missing", "duplicate", "foreign-turn", "accept", "expired", "after-delivery"])("rejects %s durable resolution", kind => {
|
||||
const f = fixture(), r = f.events[5]!, p = r.payload.prpEvent;
|
||||
if (kind === "missing") f.events.pop();
|
||||
if (kind === "duplicate") f.events.push(structuredClone(r));
|
||||
if (kind === "foreign-turn") p.payload.turnId = "foreign";
|
||||
if (kind === "accept") p.payload.action = "accept";
|
||||
if (kind === "expired") r.eventType = p.eventType = "runtime_request.expired";
|
||||
if (kind === "after-delivery") { p.sourceSeq = 114; p.sourceEventId = "runner-instance:run:114"; }
|
||||
expect(() => readCopilotDenialSettlement(f)).toThrow();
|
||||
});
|
||||
it("captures the exact durable stream at the sampling boundary without clock conversion", () => {
|
||||
const f = fixture();
|
||||
expect(copilotDenialSampleCursor([f.events[0]], f.request)).toEqual({ runId: "run", turnId: "turn", normalizedSessionId: "normalized-session", sourceInstanceId: "runner-instance", sourceSeq: 111 });
|
||||
expect(copilotDenialSampleCursor(f.events, f.request).sourceSeq).toBe(121);
|
||||
f.events[1]!.payload.prpEvent.normalizedSessionId = "foreign";
|
||||
expect(() => copilotDenialSampleCursor(f.events, f.request)).toThrow(/cursor/);
|
||||
});
|
||||
it("awaits exact pre-Stop receipt retention and waits for post-Stop cancelled terminal before sampling", async () => {
|
||||
vi.useFakeTimers(); let mono = 100n; vi.spyOn(process.hrtime, "bigint").mockImplementation(() => ++mono);
|
||||
try {
|
||||
const f = fixture("turn.cancelled"); let loads = 0, dispatched = false, postLoads = 0;
|
||||
let release!: () => void; const retention = new Promise<void>(resolve => { release = resolve; });
|
||||
const post = stopPost(f, () => { dispatched = true; });
|
||||
const retainPreStop = vi.fn(async (receipt) => { expect(receipt.terminal).toBeNull(); await retention; });
|
||||
const afterDeniedEdit = vi.fn(async () => {}), afterSettlement = vi.fn(async () => {});
|
||||
const result = settleCopilotDeniedRun({ api: { post } as any, request: f.request, deadlineAt: Date.now() + 5000,
|
||||
load: async () => {
|
||||
loads++; if (dispatched) postLoads++;
|
||||
const events = f.events.filter((_, i) => !(loads === 1 && i === 3) && !((!dispatched || postLoads === 1) && i === 4));
|
||||
return { ...f, events, run: dispatched ? f.run : liveRun(f), retired: dispatched };
|
||||
}, afterDeniedEdit, retainPreStop, afterSettlement });
|
||||
await vi.advanceTimersByTimeAsync(0); expect(post).not.toHaveBeenCalled(); expect(afterDeniedEdit).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(200); expect(afterDeniedEdit).toHaveBeenCalledOnce(); expect(post).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(2000); expect(retainPreStop).toHaveBeenCalledOnce(); expect(post).not.toHaveBeenCalled();
|
||||
release(); await vi.advanceTimersByTimeAsync(0); expect(post).toHaveBeenCalledOnce(); expect(afterSettlement).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(200); expect((await result).branch).toBe("provider_cancelled_or_interrupted"); expect(afterSettlement).toHaveBeenCalledOnce();
|
||||
} finally { vi.restoreAllMocks(); vi.useRealTimers(); }
|
||||
});
|
||||
it("rejects normal completion first returned after Stop even with an earlier transaction timestamp", async () => {
|
||||
vi.useFakeTimers(); let mono = 100n; vi.spyOn(process.hrtime, "bigint").mockImplementation(() => ++mono);
|
||||
try {
|
||||
const f = fixture(); f.events[4]!.createdAt = date(1); let dispatched = false;
|
||||
const sample = vi.fn(async () => {}), post = stopPost(f, () => { dispatched = true; });
|
||||
const retain = vi.fn(async (receipt) => { expect(receipt.terminal).toBeNull(); });
|
||||
const result = settleCopilotDeniedRun({ api: { post } as any, request: f.request, deadlineAt: Date.now() + 3000,
|
||||
load: async () => ({ ...f, run: dispatched ? f.run : liveRun(f), events: dispatched ? f.events : f.events.filter((_, i) => i !== 4), retired: dispatched }),
|
||||
afterDeniedEdit: async () => {}, retainPreStop: retain, afterSettlement: sample });
|
||||
const rejected = expect(result).rejects.toThrow(/Timed out waiting for correlated provider settlement/);
|
||||
await vi.advanceTimersByTimeAsync(3200); await rejected;
|
||||
expect(retain).toHaveBeenCalledOnce(); expect(post).toHaveBeenCalledOnce(); expect(sample).not.toHaveBeenCalled();
|
||||
} finally { vi.restoreAllMocks(); vi.useRealTimers(); }
|
||||
});
|
||||
it.each(["missing-edit", "foreign-edit", "missing-terminal", "foreign-terminal", "live-process"])("fails closed on %s without premature side effects or terminal samples", async kind => {
|
||||
vi.useFakeTimers(); let mono = 100n; vi.spyOn(process.hrtime, "bigint").mockImplementation(() => ++mono);
|
||||
try {
|
||||
const f = fixture(), post = stopPost(f), sample = vi.fn(async () => {});
|
||||
if (kind === "missing-edit") f.events.splice(3, 1);
|
||||
if (kind === "foreign-edit") f.events[3]!.payload.prpEvent.normalizedSessionId = "foreign";
|
||||
if (kind === "missing-terminal") f.events.splice(4, 1);
|
||||
if (kind === "foreign-terminal") terminal(f).sourceInstanceId = "foreign";
|
||||
const result = settleCopilotDeniedRun({ api: { post } as any, request: f.request, deadlineAt: Date.now() + 3000,
|
||||
load: async () => ({ ...f, run: post.mock.calls.length ? f.run : liveRun(f), retired: kind !== "live-process" }), afterDeniedEdit: async () => {}, retainPreStop: async () => {}, afterSettlement: sample });
|
||||
const rejected = expect(result).rejects.toThrow();
|
||||
await vi.advanceTimersByTimeAsync(3200); await rejected;
|
||||
expect(post).toHaveBeenCalledTimes(kind.endsWith("edit") || kind === "foreign-terminal" ? 0 : 1);
|
||||
expect(sample).not.toHaveBeenCalled();
|
||||
} finally { vi.restoreAllMocks(); vi.useRealTimers(); }
|
||||
});
|
||||
it.each(["failed", "timed_out"])("reports %s promptly even when required rows are missing", async status => {
|
||||
const f = fixture(), post = stopPost(f), sample = vi.fn(async () => {});
|
||||
const load = vi.fn(async () => ({ ...f, events: [], run: { ...f.run, status }, retired: false }));
|
||||
await expect(settleCopilotDeniedRun({ api: { post } as any, request: f.request, deadlineAt: Date.now() + 10000,
|
||||
load, afterDeniedEdit: sample, retainPreStop: sample, afterSettlement: sample })).rejects.toThrow(`Stopped waiting for persisted correlated failed native edit: Copilot provider run failed`);
|
||||
expect(load).toHaveBeenCalledOnce(); expect(post).not.toHaveBeenCalled(); expect(sample).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["failed", "timed_out"])("reports post-Stop %s before attempting a missing terminal reader", async status => {
|
||||
const f = fixture(), post = stopPost(f); let loads = 0;
|
||||
const load = vi.fn(async () => ({ ...f, events: ++loads < 4 ? f.events : [], run: loads < 4 ? liveRun(f) : { ...f.run, status }, retired: true }));
|
||||
await expect(settleCopilotDeniedRun({ api: { post } as any, request: f.request, deadlineAt: Date.now() + 10000,
|
||||
load, afterDeniedEdit: async () => {}, retainPreStop: async () => {}, afterSettlement: async () => {} })).rejects.toThrow("Stopped waiting for correlated provider settlement and retired run: Copilot provider run failed");
|
||||
expect(load).toHaveBeenCalledTimes(4); expect(post).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("does not dispatch Stop if the pre-dispatch receipt cannot be retained", async () => {
|
||||
const f = fixture(), post = stopPost(f);
|
||||
await expect(settleCopilotDeniedRun({ api: { post } as any, request: f.request, deadlineAt: Date.now() + 1000,
|
||||
load: async () => ({ ...f, run: post.mock.calls.length ? f.run : liveRun(f), retired: true }), afterDeniedEdit: async () => {}, afterSettlement: async () => {},
|
||||
retainPreStop: async () => { throw new Error("artifact write failed"); } })).rejects.toThrow("artifact write failed");
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
it("retains completed-before-Stop settlement and propagates retirement sampling failure", async () => {
|
||||
const f = fixture(), post = stopPost(f);
|
||||
const input = { api: { post } as any, request: f.request, deadlineAt: Date.now() + 1000,
|
||||
load: async () => ({ ...f, run: post.mock.calls.length ? f.run : liveRun(f), retired: true }), afterDeniedEdit: async () => {}, retainPreStop: async () => {}, afterSettlement: async () => {} };
|
||||
expect((await settleCopilotDeniedRun(input)).branch).toBe("provider_completed_observed_before_stop");
|
||||
post.mockClear();
|
||||
await expect(settleCopilotDeniedRun({ ...input, afterSettlement: async () => { throw new Error("remote descendants live"); } })).rejects.toThrow("remote descendants live");
|
||||
});
|
||||
it.each(["cancelled", "pending-intent", "startup-only"])("rejects visible earlier Stop %s before dispatch", async kind => {
|
||||
const f = fixture(), post = stopPost(f);
|
||||
const run = kind === "cancelled" ? f.run : { ...liveRun(f), resultJson: kind === "startup-only"
|
||||
? { startupCancellation: { requestedAt: date(1) } } : { nativeCancellation: { intentId: "earlier", dispatchState: "pending" } } };
|
||||
await expect(settleCopilotDeniedRun({ api: { post } as any, request: f.request, deadlineAt: Date.now() + 1000,
|
||||
load: async () => ({ ...f, run, retired: false }), afterDeniedEdit: async () => {}, retainPreStop: async () => {}, afterSettlement: async () => {},
|
||||
})).rejects.toThrow("earlier Stop");
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects Stop racing during artifact retention", async () => {
|
||||
const f = fixture(), post = stopPost(f); let earlier = false;
|
||||
await expect(settleCopilotDeniedRun({ api: { post } as any, request: f.request, deadlineAt: Date.now() + 1000,
|
||||
load: async () => ({ ...f, run: earlier ? f.run : liveRun(f), retired: false }), afterDeniedEdit: async () => {},
|
||||
retainPreStop: async () => { earlier = true; }, afterSettlement: async () => {},
|
||||
})).rejects.toThrow("earlier Stop");
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["conflict", "foreign-response"])("rejects a conflicting or foreign-caller Stop API response: %s", async kind => {
|
||||
const f = fixture(); const post = vi.fn(async (_path: string) => { if (kind === "conflict") throw new Error("409 cancellation intent conflict"); f.run.resultJson.cancelledByUserId = "foreign-caller"; return f.run; });
|
||||
const sampled = vi.fn(async () => {});
|
||||
await expect(settleCopilotDeniedRun({ api: { post } as any, request: f.request, deadlineAt: Date.now() + 1000,
|
||||
load: async () => ({ ...f, run: liveRun(f), retired: false }), afterDeniedEdit: async () => {}, retainPreStop: async () => {}, afterSettlement: sampled,
|
||||
})).rejects.toThrow(kind === "conflict" ? "409" : "Stop acknowledgement");
|
||||
expect(post).toHaveBeenCalledOnce(); expect(sampled).not.toHaveBeenCalled();
|
||||
expect(post.mock.calls[0]).toEqual(["/api/heartbeat-runs/run/cancel"]);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { RunnerTaskFixture } from "./types.js";
|
||||
import { copilotProtectionCases } from "./copilot-protection-cases.js";
|
||||
import { contextOrientation } from "./copilot-active-stop-tasks.js";
|
||||
export const copilotProtectionTasks: readonly RunnerTaskFixture[] = copilotProtectionCases.map(c => ({
|
||||
id: c.id, label: c.id === "native-permission-deny-write" ? "Deny one native write, then stop safely" : "Attached command settles before completion",
|
||||
groups: [], workMode: "standard", flow: "copilot_protection", expectedRunCount: 1,
|
||||
attemptTimeoutMs: { local: 300_000, daytona: 300_000 },
|
||||
expectedTerminalState: { issue: c.id === "native-permission-deny-write" ? "in_progress" : "done", run: c.id === "native-permission-deny-write" ? "cancelled" : "succeeded" },
|
||||
buildTitle: nonce => `Copilot ${c.id} ${nonce}`,
|
||||
buildVisibleMarker: nonce => `COPILOT-PROTECTION-${c.id}-${nonce}`,
|
||||
buildPrompt: nonce => c.id === "native-permission-deny-write" ? contextOrientation + c.prompt(nonce) : [
|
||||
"Run the supplied exact command once using native bash with mode async and detach false.",
|
||||
"After starting it, attempt to finish immediately without calling read_bash or another waiting tool. The attached-process runtime must settle the command before ending the turn.",
|
||||
"Do not modify fixture code, manufacture its result, or start another command.",
|
||||
"The fixture-owned marker is a private diagnostic sentinel, not a requested file deliverable. Do not publish or attach it.",
|
||||
`Call paperclip_finish with reportedWorkDisposition done, summary COPILOT-PROTECTION-${c.id}-${nonce}, current completion contract revision, satisfied objective criterion, no remaining work, evidence [], verification []. After acceptance reply with that exact summary.`,
|
||||
].join("\n"),
|
||||
buildMatchers: () => [], // Flow grades public durable origin and independent OS/filesystem observations.
|
||||
}));
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canonicalRemoteCopilotCommand, copilotDeathArguments, selectOwnedCopilotProcess } from "./copilot-provider-death.js";
|
||||
|
||||
describe("owned Copilot provider death selector", () => {
|
||||
const root = { pid: 10, parent: 1 }, sidecar = { pid: 11, parent: 10 }, native = { pid: 12, parent: 11 };
|
||||
const argv = ["/private/paperclip-acpx-native-owned/distribution/copilot", ...copilotDeathArguments];
|
||||
it("selects only the native child of the live owned run", () => {
|
||||
expect(selectOwnedCopilotProcess(10, [root, sidecar, native], [10, 11, 12], new Map([[12, argv]]))).toEqual(native);
|
||||
});
|
||||
it.each([3, 7])("recognizes a verified inherited Linux descriptor %i", fd => {
|
||||
const command = [`/proc/self/fd/${fd}`, ...copilotDeathArguments];
|
||||
const canonical = canonicalRemoteCopilotCommand(command, argv[0]!, { dev: "1", ino: "2" }, { path: argv[0]!, dev: "1", ino: "2" });
|
||||
expect(selectOwnedCopilotProcess(10, [root, sidecar, native], [10, 11, 12], new Map([[12, canonical]]))).toEqual(native);
|
||||
});
|
||||
it("refuses descriptor aliases without matching executable identity", () => {
|
||||
for (const [command, executable, descriptor] of [
|
||||
[["/proc/self/fd/8", ...copilotDeathArguments], argv[0], { path: argv[0], dev: "1", ino: "2" }],
|
||||
[["/proc/self/fd/7", ...copilotDeathArguments], "/usr/bin/copilot", { path: "/usr/bin/copilot", dev: "1", ino: "2" }],
|
||||
[["/proc/self/fd/7", ...copilotDeathArguments], argv[0], undefined],
|
||||
[["/proc/self/fd/7", ...copilotDeathArguments], argv[0], { path: "/foreign/copilot", dev: "1", ino: "2" }],
|
||||
[["/proc/self/fd/7", ...copilotDeathArguments], argv[0], { path: argv[0], dev: "2", ino: "2" }],
|
||||
[["/proc/self/fd/7", ...copilotDeathArguments], argv[0], { path: argv[0], dev: "1", ino: "3" }],
|
||||
] as const) {
|
||||
const canonical = canonicalRemoteCopilotCommand(command, executable!, { dev: "1", ino: "2" }, descriptor as any);
|
||||
expect(() => selectOwnedCopilotProcess(10, [root, sidecar, native], [10, 11, 12], new Map([[12, canonical]]))).toThrow();
|
||||
}
|
||||
});
|
||||
it("refuses foreign, retired, ambiguous and caller-selected processes", () => {
|
||||
const commands = new Map([[12, argv]]);
|
||||
expect(() => selectOwnedCopilotProcess(10, [root, sidecar, { ...native, parent: 99 }], [10, 11, 12], commands)).toThrow();
|
||||
expect(() => selectOwnedCopilotProcess(10, [root, sidecar, native], [11, 12], commands)).toThrow();
|
||||
expect(() => selectOwnedCopilotProcess(10, [root, sidecar, native], [10, 11], commands)).toThrow();
|
||||
expect(() => selectOwnedCopilotProcess(10, [root, sidecar, native, { pid: 13, parent: 11 }], [10, 11, 12, 13], new Map([[12, argv], [13, argv]]))).toThrow();
|
||||
for (const invalid of [["/usr/bin/copilot", ...copilotDeathArguments], [...argv, "--allow-all"], [argv[0]!, "--acp"]]) expect(() => selectOwnedCopilotProcess(10, [root, sidecar, native], [10, 11, 12], new Map([[12, invalid]]))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
import { assertCopilotProviderDeath } from "./copilot-provider-death.js";
|
||||
import type { ActiveStopPending } from "./copilot-active-stop-evidence.js";
|
||||
|
||||
describe("provider-death independent evidence", () => {
|
||||
const pending = { scope: { companyId: "company", runId: "run", issueId: "issue", provider: "copilot", target: "target.txt" }, requestId: "request", toolCallId: "tool", turnId: "turn", normalizedSessionId: "session" } as ActiveStopPending;
|
||||
const row = (seq: number, eventType: string, payload: Record<string, unknown>) => ({ companyId: "company", runId: "run", seq, eventType, protocolSchemaVersion: 1, payload: { prpEvent: { schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", eventType, runId: "run", turnId: "turn", normalizedSessionId: "session", sourceInstanceId: "source", sourceSeq: seq, sourceEventId: `source:run:${seq}`, emittedAt: "2026-10-03T00:00:00Z", payload } } });
|
||||
const notice = row(1, "provider.notice.recorded", { schema: "paperclip.provider.notice.v1", scope: "turn", category: "copilot_tool_evidence_v1", provenance: { sessionId: "native-session", turnId: "turn", eventType: "permission_requested", method: "session/request_permission" }, details: Object.entries({ stage: "permission_requested", toolCallId: "tool", requestId: "request", operation: "edit", target: "target.txt", declineOffered: "true" }).map(([name, value]) => ({ name, value })) });
|
||||
const facts = () => ({ pending, run: { id: "run", companyId: "company", nativeIssueId: "issue", runtimeMode: "native", status: "failed" }, issue: { id: "issue", status: "blocked" }, events: [notice, row(2, "runtime_request.expired", { requestId: "request", turnId: "turn", requestKind: "permission_approval", reason: "provider_exit" }), row(3, "turn.failed", { error: { code: "AGENT_DISCONNECTED", message: "Provider exited" } })] });
|
||||
it("accepts callback expiry and an unfinished failed task with no replay", () => {
|
||||
expect(assertCopilotProviderDeath(facts())).toMatchObject({ expired: true, mutationReplay: false });
|
||||
});
|
||||
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();
|
||||
const succeeded = facts(); succeeded.run.status = "succeeded"; expect(() => assertCopilotProviderDeath(succeeded)).toThrow();
|
||||
const done = facts(); done.issue.status = "done"; expect(() => assertCopilotProviderDeath(done)).toThrow();
|
||||
const foreign = facts(); foreign.events[0]!.companyId = "foreign"; expect(() => assertCopilotProviderDeath(foreign)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { COPILOT_LAUNCH_ARGUMENTS } from "../../packages/paperclip-runner/src/drivers/acpx/copilot-profile.js";
|
||||
import { isValidNativePrpEnvelope } from "./native-event-envelope.js";
|
||||
import { copilotEditOriginForPermission, readCopilotToolEvidence } from "./copilot-evidence.js";
|
||||
import { readCopilotContextRead } from "./copilot-context-evidence.js";
|
||||
import type { ActiveStopPending } from "./copilot-active-stop-evidence.js";
|
||||
|
||||
/** Closed selector shared by local and remote observers. It accepts no caller PID.
|
||||
* Only a live native executable under an already owned run root is eligible. */
|
||||
export function selectOwnedCopilotProcess<T extends { pid: number; parent: number }>(
|
||||
rootPid: number, journal: readonly T[], live: readonly number[], argv: ReadonlyMap<number, readonly string[]>, fixedArguments: readonly string[] = COPILOT_LAUNCH_ARGUMENTS,
|
||||
): T {
|
||||
const owned = new Map(journal.map(row => [row.pid, row]));
|
||||
if (!owned.has(rootPid) || !live.includes(rootPid)) throw new Error("Copilot death requires a live owned run root");
|
||||
const candidates = journal.filter(row => {
|
||||
if (row.pid === rootPid || !live.includes(row.pid)) return false;
|
||||
const command = argv.get(row.pid);
|
||||
if (!command || command.length !== fixedArguments.length + 1 || !/^\/[^\u0000-\u0020]*\/paperclip-acpx-native-[A-Za-z0-9_-]+\/distribution\/copilot$/u.test(command[0] ?? "") || !fixedArguments.every((arg, i) => command[i + 1] === arg)) return false;
|
||||
const seen = new Set<number>(); let parent = row.parent;
|
||||
while (parent !== rootPid) {
|
||||
if (seen.has(parent) || !live.includes(parent)) return false;
|
||||
seen.add(parent); const ancestor = owned.get(parent); if (!ancestor) return false; parent = ancestor.parent;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (candidates.length !== 1) throw new Error("Copilot death requires exactly one owned native process");
|
||||
return candidates[0]!;
|
||||
}
|
||||
export const copilotDeathArguments = [...COPILOT_LAUNCH_ARGUMENTS];
|
||||
|
||||
/** Linux launches the held, verified executable descriptor rather than reopening
|
||||
* its name. Canonicalize only that closed launcher shape, after the observer
|
||||
* proves the live executable and inherited descriptor name the same inode. */
|
||||
export function canonicalRemoteCopilotCommand(
|
||||
command: readonly string[], executable: string,
|
||||
executableIdentity: { dev: string; ino: string },
|
||||
descriptor?: { path: string; dev: string; ino: string },
|
||||
): readonly string[] {
|
||||
if (!/^\/[^\u0000-\u0020]*\/paperclip-acpx-native-[A-Za-z0-9_-]+\/distribution\/copilot$/u.test(executable)) return command;
|
||||
if (command[0] === executable) return command;
|
||||
if (!/^\/proc\/self\/fd\/(?:3|7)$/u.test(command[0] ?? "") || !descriptor
|
||||
|| descriptor.path !== executable || descriptor.dev !== executableIdentity.dev || descriptor.ino !== executableIdentity.ino) return command;
|
||||
return [executable, ...command.slice(1)];
|
||||
}
|
||||
export function copilotDeathCommandDigest(argv: readonly string[]) {
|
||||
return `sha256:${createHash("sha256").update(JSON.stringify(argv)).digest("hex")}`;
|
||||
}
|
||||
|
||||
/** A failed run alone is insufficient: the pending callback must expire and its
|
||||
* native operation must never complete or appear again after the stale answer. */
|
||||
export function assertCopilotProviderDeath(input: { pending: ActiveStopPending; run: Record<string, any>; issue: Record<string, any>; events: readonly any[] }) {
|
||||
const { pending, run, issue } = input;
|
||||
if (run.id !== pending.scope.runId || run.companyId !== pending.scope.companyId || issue.id !== pending.scope.issueId || issue.status !== "blocked" || run.nativeIssueId !== pending.scope.issueId || run.runtimeMode !== "native" || run.status !== "failed") throw new Error("Provider death requires one failed unfinished run");
|
||||
const rows = input.events.filter(row => row.payload?.prpEvent);
|
||||
if (!rows.length || rows.some(row => row.runId !== run.id || row.companyId !== run.companyId || !isValidNativePrpEnvelope(row.payload.prpEvent, row.protocolSchemaVersion))) throw new Error("Provider death requires canonical run evidence");
|
||||
const events = rows.map(row => row.payload.prpEvent);
|
||||
const expiry = events.filter(event => event.eventType === "runtime_request.expired" && event.payload?.requestId === pending.requestId && event.turnId === pending.turnId && event.normalizedSessionId === pending.normalizedSessionId);
|
||||
if (expiry.length !== 1 || events.some(event => event.eventType === "runtime_request.resolved" && event.payload?.requestId === pending.requestId) || events.some(event => event.eventType === "task.completed" || event.eventType === "turn.completed")) throw new Error("Provider death did not expire the unanswered callback");
|
||||
const allNotices = readCopilotToolEvidence(input.events, run.id);
|
||||
const target = copilotEditOriginForPermission(allNotices, pending.scope.target, pending.toolCallId);
|
||||
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 (!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 };
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { onlyCopilotAttachedOperations, readCopilotSemanticCompletion } from "./copilot-semantic-evidence.js";
|
||||
import { readCopilotToolEvidence } from "./copilot-evidence.js";
|
||||
import { copilotActionNotices } from "./copilot-protection-evidence.js";
|
||||
import { validatePrpStructuredRunResult } from "../../packages/paperclip-runner/src/protocol/replay-contract.js";
|
||||
import { runnerSuites, suiteDefinitionHash } from "./catalog.js";
|
||||
const hash = (v: string) => createHash("sha256").update(v).digest("hex");
|
||||
const canonical = (v: any): string => Array.isArray(v) ? `[${v.map(canonical).join(",")}]` : v !== null && typeof v === "object" ? `{${Object.keys(v).sort().map(k => `${JSON.stringify(k)}:${canonical(v[k])}`).join(",")}}` : JSON.stringify(v);
|
||||
const rawInput = { summary: "EXACT-MARKER", reportedWorkDisposition: "done", completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: [] }], remainingWork: [] }, evidence: [], verification: [] };
|
||||
const validation = validatePrpStructuredRunResult(rawInput);
|
||||
if (!validation.ok) throw new Error("Semantic fixture input must pass the production validator");
|
||||
const result = validation.result;
|
||||
const receipt = { schema: "paperclip.semantic_tool_receipt.v2", operationId: "paperclip_finish", callIdentitySha256: hash("3"), inputSha256: hash(canonical(rawInput)), normalizedInputSha256: hash(canonical(result)), resultSha256: hash("opaque original returned encoding"), outcome: "returned" };
|
||||
function row(seq: number, eventType: string, payload: any, overrides: any = {}) {
|
||||
const e = { schema: "paperclip.prp.event.v1", eventType, runId: "run", turnId: "turn", normalizedSessionId: "normalized", sourceInstanceId: "source", sourceKind: "runner", sourceSeq: seq, emittedAt: "2026-09-30T12:00:00Z", payload, ...overrides };
|
||||
return { companyId: "company", runId: "run", seq, eventType, sourceInstanceId: e.sourceInstanceId, sourceSeq: e.sourceSeq, payload: { prpEvent: e } };
|
||||
}
|
||||
function notice(seq: number, toolCallId: string, status: string, fields: any = {}) {
|
||||
return row(seq, "provider.notice.recorded", { schema: "paperclip.provider.notice.v1", category: "copilot_tool_evidence_v1", scope: "turn", provenance: { method: "session/update", eventType: "tool", sessionId: "native", turnId: "turn" }, details: Object.entries({ stage: "tool", toolCallId, status, ...fields }).map(([name, value]) => ({ name, value: String(value) })) });
|
||||
}
|
||||
function fixture() {
|
||||
const rows = [notice(1, "command", "pending", { operation: "execute" }), notice(2, "finish-native", "pending"), row(3, "run.result.proposed", structuredClone(result), { itemId: "item-run" }),
|
||||
row(4, "provider.notice.recorded", { schema: "paperclip.provider.notice.v1", category: "paperclip_semantic_tool_receipt_v2", scope: "turn", provenance: { method: "paperclip/semantic_tool_result", eventType: "semantic_result", sessionId: "native", turnId: "turn" }, details: Object.entries({ stage: "semantic_result", ...receipt }).map(([name, value]) => ({ name, value })) }),
|
||||
notice(5, "finish-native", "completed", { semanticOperationId: receipt.operationId, semanticCallIdentitySha256: receipt.callIdentitySha256, semanticInputSha256: receipt.inputSha256, semanticNormalizedInputSha256: receipt.normalizedInputSha256, semanticResultSha256: receipt.resultSha256, semanticOutcome: receipt.outcome }),
|
||||
row(6, "turn.completed", {}), row(7, "run.result.accepted", { result: structuredClone(result) }, { sourceKind: "control_plane", sourceInstanceId: "source:control", sourceSeq: 1 })];
|
||||
const expected = { companyId: "company", runId: "run", turnId: "turn", nativeSessionId: "native", command: readCopilotToolEvidence(rows, "run")[0]!, summary: "EXACT-MARKER" };
|
||||
return { rows, expected };
|
||||
}
|
||||
const frame = (r: any) => r.payload.prpEvent;
|
||||
function setField(r: any, key: string, value: string) { frame(r).payload.details.find((d: any) => d.name === key).value = value; }
|
||||
describe("Copilot semantic completion public-event oracle", () => {
|
||||
it("attests the retained v15 context read before independently accepting its exact finish", async () => {
|
||||
const retained = JSON.parse(await readFile(new URL("./fixtures/copilot-context-attached-completion-v15.json", import.meta.url), "utf8"));
|
||||
const notices = readCopilotToolEvidence(retained.rows, retained.rows[0].runId);
|
||||
const command = notices.find(n => n.operation === "execute" && n.status === "pending")!;
|
||||
const proof = readCopilotSemanticCompletion(retained.rows, { companyId: retained.rows[0].companyId,
|
||||
runId: command.runId, turnId: command.turnId, nativeSessionId: command.sessionId, command,
|
||||
summary: retained.summary, requireContextRead: true });
|
||||
expect(proof).toMatchObject({ schema: "paperclip.e2e.copilot-semantic-completion.v4", contextRead: { canonicalSeqs: expect.any(Array) } });
|
||||
expect(proof.contextRead!.completedSeq).toBeLessThan(command.seq);
|
||||
expect(onlyCopilotAttachedOperations(copilotActionNotices(notices, command, undefined, retained.rows), command, proof)).toBe(true);
|
||||
for (const invalid of ["missing", "duplicate", "error", "foreign", "late", "mutating", "wrong-input", "missing-canonical", "failed-canonical", "native-mutation", "canonical-mutation", "not-read-only", "source-sequence-drift", "extra-context-call"]) {
|
||||
const rows = structuredClone(retained.rows);
|
||||
const receipt = rows.find((r: any) => r.seq === proof.contextRead!.receiptSeq)!;
|
||||
if (invalid === "missing") rows.splice(rows.indexOf(receipt), 1);
|
||||
if (invalid === "duplicate") rows.push(structuredClone(receipt));
|
||||
if (invalid === "error") setField(receipt, "outcome", "error");
|
||||
if (invalid === "foreign") frame(receipt).sourceInstanceId = "foreign";
|
||||
if (invalid === "late") receipt.seq = command.seq + 1;
|
||||
if (invalid === "mutating") setField(receipt, "operationId", "report_progress");
|
||||
if (invalid === "wrong-input") setField(receipt, "inputSha256", hash("foreign"));
|
||||
const canonical = rows.find((r: any) => r.seq === proof.contextRead!.canonicalSeqs.at(-1))!;
|
||||
if (invalid === "missing-canonical") rows.splice(rows.indexOf(canonical), 1);
|
||||
if (invalid === "failed-canonical") frame(canonical).payload.status = "failed";
|
||||
if (invalid === "native-mutation") setField(rows.find((r: any) => r.seq === proof.contextRead!.pendingSeq), "operation", "edit");
|
||||
if (invalid === "canonical-mutation") frame(canonical).payload.operation = "edit";
|
||||
if (invalid === "not-read-only") frame(canonical).payload.readOnly = false;
|
||||
if (invalid === "source-sequence-drift") { frame(canonical).sourceSeq = command.seq + 1; canonical.sourceSeq = command.seq + 1; }
|
||||
if (invalid === "extra-context-call") rows.push(structuredClone(rows.find((r: any) => r.seq === proof.contextRead!.completedSeq)));
|
||||
expect(() => readCopilotSemanticCompletion(rows, { companyId: rows[0].companyId,
|
||||
runId: command.runId, turnId: command.turnId, nativeSessionId: command.sessionId, command,
|
||||
summary: retained.summary, requireContextRead: true }), invalid).toThrow();
|
||||
}
|
||||
});
|
||||
it("cannot qualify a historical finish without the now-required context read", () => {
|
||||
const f = fixture();
|
||||
expect(() => readCopilotSemanticCompletion(f.rows, { ...f.expected, requireContextRead: true })).toThrow("context read");
|
||||
});
|
||||
it("versions semantic completion and current-mainline denial settlement independently", () => {
|
||||
const suite = runnerSuites.find(s => s.id === "copilot-protection")!;
|
||||
expect(suite.definitionMetadata).toMatchObject({ version: 20, titleCreation: "explicit-search-composer", taskBinding: "browser-creation-response-id", remoteBootstrapContext: "already-exposed-dedicated-tool-no-http-substitution", semanticCompletionEvidence: "paperclip.e2e.copilot-semantic-completion.v4", denialSettlementEvidence: "paperclip.e2e.copilot-denial-settlement.v4" });
|
||||
expect(suiteDefinitionHash(suite)).not.toBe(suiteDefinitionHash({ ...suite, definitionMetadata: { ...suite.definitionMetadata, version: 8 } }));
|
||||
});
|
||||
it("joins exact native lifecycle, authoritative callback, proposed content and accepted control-plane result", () => {
|
||||
const { rows, expected } = fixture(); const proof = readCopilotSemanticCompletion(rows, expected);
|
||||
expect(proof.nativeToolCallId).toBe("finish-native"); expect(proof.acceptedSeq).toBe(7); expect(proof.summarySha256).toBe(hash("EXACT-MARKER"));
|
||||
expect(JSON.stringify(proof)).not.toContain("EXACT-MARKER");
|
||||
});
|
||||
it("binds distinct raw and production-normalized input digests without filling defaults in the oracle", () => {
|
||||
const { rows, expected } = fixture();
|
||||
expect(rawInput).not.toHaveProperty("schema");
|
||||
expect(rawInput).not.toHaveProperty("artifacts");
|
||||
expect(rawInput).not.toHaveProperty("attentionRequests");
|
||||
expect(result).toMatchObject({ schema: "paperclip.run_result.v1", artifacts: [], attentionRequests: [] });
|
||||
expect(receipt.inputSha256).not.toBe(receipt.normalizedInputSha256);
|
||||
expect(readCopilotSemanticCompletion(rows, expected)).toMatchObject({
|
||||
schema: "paperclip.e2e.copilot-semantic-completion.v2",
|
||||
inputSha256: receipt.inputSha256, normalizedInputSha256: receipt.normalizedInputSha256,
|
||||
});
|
||||
// Same authenticated raw/native join is necessary but cannot substitute for
|
||||
// the invocation-captured normalized body digest.
|
||||
setField(rows[3], "normalizedInputSha256", receipt.inputSha256);
|
||||
setField(rows[4], "semanticNormalizedInputSha256", receipt.inputSha256);
|
||||
expect(() => readCopilotSemanticCompletion(rows, expected)).toThrow("canonical accepted result");
|
||||
});
|
||||
it.each(["missing", "null", "wrong", "uppercase", "malformed"])("rejects %s normalized finish digest", value => {
|
||||
const f = fixture();
|
||||
if (value === "missing") {
|
||||
frame(f.rows[3]!).payload.details = frame(f.rows[3]!).payload.details.filter((d: any) => d.name !== "normalizedInputSha256");
|
||||
frame(f.rows[4]!).payload.details = frame(f.rows[4]!).payload.details.filter((d: any) => d.name !== "semanticNormalizedInputSha256");
|
||||
} else {
|
||||
const digest = value === "null" ? "null" : value === "wrong" ? hash("foreign-normalized-input") : value === "uppercase" ? "A".repeat(64) : "not-a-digest";
|
||||
setField(f.rows[3], "normalizedInputSha256", digest); setField(f.rows[4], "semanticNormalizedInputSha256", digest);
|
||||
}
|
||||
expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow();
|
||||
});
|
||||
it("allows an explicit null native digest as diagnostic data, never as successful finish authority", () => {
|
||||
const f = fixture(); setField(f.rows[4], "semanticNormalizedInputSha256", "null");
|
||||
expect(readCopilotToolEvidence(f.rows, "run").at(-1)?.semanticNormalizedInputSha256).toBeNull();
|
||||
expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow("native receipt mismatch");
|
||||
});
|
||||
it.each(["only", "alongside-v2"])("rejects legacy v1 authority %s as fresh qualification", mode => {
|
||||
const f = fixture(), old = structuredClone(f.rows[3]!);
|
||||
frame(old).payload.category = "paperclip_semantic_tool_receipt_v1";
|
||||
setField(old, "schema", "paperclip.semantic_tool_receipt.v1");
|
||||
frame(old).payload.details = frame(old).payload.details.filter((d: any) => d.name !== "normalizedInputSha256");
|
||||
if (mode === "only") f.rows[3] = old; else f.rows.push(old);
|
||||
expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow();
|
||||
});
|
||||
it("does not repair an unnormalized proposed and accepted body by adding defaults", () => {
|
||||
const f = fixture(); frame(f.rows[2]!).payload = structuredClone(rawInput); frame(f.rows[6]!).payload.result = structuredClone(rawInput);
|
||||
expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow("canonical accepted result");
|
||||
});
|
||||
it.each([0, 1, 2, 3, 4, 5, 6])("rejects missing required row %s", index => { const f = fixture(); f.rows.splice(index, 1); expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow(); });
|
||||
it.each([1, 2, 3, 4, 5, 6])("rejects duplicate required row %s", index => { const f = fixture(); f.rows.push(structuredClone(f.rows[index]!)); expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow(); });
|
||||
it.each(["companyId", "runId"])("rejects foreign durable %s", key => { const f = fixture(); (f.rows[3] as any)[key] = "foreign"; expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow(); });
|
||||
it.each(["turnId", "normalizedSessionId", "sourceInstanceId", "sourceKind", "eventType", "schema"])("rejects foreign or malformed envelope %s", key => {
|
||||
for (const index of [1, 2, 3, 4, 5, 6]) { const f = fixture(); (frame(f.rows[index]!))[key] = "foreign"; expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow(); }
|
||||
});
|
||||
it.each([null, undefined])("rejects missing envelope turn %s even with native provenance intact", turn => { const f = fixture(); frame(f.rows[4]!).turnId = turn; expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow(); });
|
||||
it.each(["sessionId", "turnId", "method", "eventType"])("rejects authoritative provenance %s mismatch", key => { const f = fixture(); frame(f.rows[3]!).payload.provenance[key] = "foreign"; expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow(); });
|
||||
it.each(["callIdentitySha256", "inputSha256", "normalizedInputSha256", "resultSha256", "operationId", "outcome"])("rejects native %s disagreement", key => { const f = fixture(); setField(f.rows[4], `semantic${key[0]!.toUpperCase()}${key.slice(1)}`, key.endsWith("Sha256") ? "a".repeat(64) : "wrong"); expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow(); });
|
||||
it("does not infer acceptance from returned receipt or a tool named paperclip_finish", () => {
|
||||
const f = fixture(); frame(f.rows[6]!).payload.result = { accepted: false, summary: "EXACT-MARKER" }; expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow();
|
||||
const g = fixture(); frame(g.rows[4]!).payload.title = "paperclip_finish"; frame(g.rows[4]!).payload.details = frame(g.rows[4]!).payload.details.filter((d: any) => !d.name.startsWith("semantic")); expect(() => readCopilotSemanticCompletion(g.rows, g.expected)).toThrow();
|
||||
});
|
||||
it.each(["failed", "cancelled", "interrupted"])("rejects %s terminal", status => { const f = fixture(); f.rows[5]!.eventType = `turn.${status}`; frame(f.rows[5]!).eventType = `turn.${status}`; expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow(); });
|
||||
it("rejects changed accepted body, forged call ID, wrong summary and rejected result", () => {
|
||||
for (const mutate of [(f: ReturnType<typeof fixture>) => { frame(f.rows[6]!).payload.result.completionClaim.remainingWork.push("unfinished"); }, (f: ReturnType<typeof fixture>) => { setField(f.rows[3], "callIdentitySha256", hash("foreign-call")); }, (f: ReturnType<typeof fixture>) => { f.expected.summary = "other"; }, (f: ReturnType<typeof fixture>) => { f.rows.push(row(8, "run.result.rejected", {})); }]) { const f = fixture(); mutate(f); expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow(); }
|
||||
});
|
||||
it("rejects duplicate/unknown/oversized receipt details and partial native fields", () => {
|
||||
for (const mutate of [(r: any) => frame(r).payload.details.push({ name: "secret", value: "untrusted" }), (r: any) => frame(r).payload.details.push(frame(r).payload.details[0]), (r: any) => setField(r, "operationId", "x".repeat(300))]) { const f = fixture(); mutate(f.rows[3]); expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow(); }
|
||||
const f = fixture(); frame(f.rows[4]!).payload.details.pop(); expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow();
|
||||
});
|
||||
it("rejects mutation metadata, extra lifecycle, reordered receipt and source sequence drift", () => {
|
||||
for (const mutate of [(f: ReturnType<typeof fixture>) => frame(f.rows[1]!).payload.details.push({ name: "operation", value: "edit" }), (f: ReturnType<typeof fixture>) => f.rows.push(notice(8, "finish-native", "pending")), (f: ReturnType<typeof fixture>) => { f.rows[3]!.seq = 8; }, (f: ReturnType<typeof fixture>) => { frame(f.rows[3]!).sourceSeq = 20; f.rows[3]!.sourceSeq = 20; }, (f: ReturnType<typeof fixture>) => { frame(f.rows[1]!).sourceSeq = 20; f.rows[1]!.sourceSeq = 20; }]) { const f = fixture(); mutate(f); expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow(); }
|
||||
});
|
||||
it("does not treat canonical run-level itemId as semantic call identity", () => {
|
||||
for (const itemId of ["item-run", "3", "unrelated-item", null]) {
|
||||
const f = fixture(); frame(f.rows[2]!).itemId = itemId;
|
||||
expect(readCopilotSemanticCompletion(f.rows, f.expected).callIdentitySha256).toBe(receipt.callIdentitySha256);
|
||||
}
|
||||
});
|
||||
it("rejects a different finish input even when proposal and acceptance agree", () => {
|
||||
const f = fixture(); frame(f.rows[2]!).payload.completionClaim.criteria[0].evidenceRefs.push("different");
|
||||
frame(f.rows[6]!).payload.result = structuredClone(frame(f.rows[2]!).payload);
|
||||
expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow();
|
||||
});
|
||||
it("accepts the retained shell-read ordering: pending has shellId only, completion gains command identity after finish", () => {
|
||||
const f = attachedFixture();
|
||||
expect(f.notices.find(n => n.toolCallId === "shell-read" && n.status === "pending")?.commandToolCallId).toBeUndefined();
|
||||
expect(onlyCopilotAttachedOperations(f.notices, f.command, f.proof)).toBe(true);
|
||||
});
|
||||
it("accepts bounded in-progress shell updates without inventing terminal command identity", () => {
|
||||
const f = attachedFixture(), pending = f.notices.find(n => n.toolCallId === "shell-read" && n.status === "pending")!;
|
||||
f.notices.push({ ...pending, status: "in_progress", seq: 53 });
|
||||
expect(onlyCopilotAttachedOperations(f.notices, f.command, f.proof)).toBe(true);
|
||||
});
|
||||
it("rejects shell-read evidence from a different durable stream despite matching native identity", () => {
|
||||
const f = attachedFixture(), r = f.rows.find(r => r.seq === 52)!;
|
||||
frame(r).sourceInstanceId = "foreign"; r.sourceInstanceId = "foreign";
|
||||
expect(() => readCopilotSemanticCompletion(f.rows, { companyId: "company", runId: "run", turnId: "turn", nativeSessionId: "native", command: f.command, summary: "EXACT-MARKER" })).toThrow();
|
||||
});
|
||||
it.each(["missing-pending", "missing-terminal", "duplicate-pending", "duplicate-terminal", "extra-completed-only", "extra-complete-read", "wrong-shell", "wrong-command", "failed", "nonzero", "reordered", "late", "wrong-operation", "mutation", "read-path", "foreign-session", "foreign-turn", "foreign-run", "permission", "semantic-fields", "normalized-semantic-field"])("rejects invalid shell-read lifecycle: %s", failure => {
|
||||
const f = attachedFixture(), start = f.notices.find(n => n.toolCallId === "shell-read" && n.status === "pending")!, end = f.notices.find(n => n.toolCallId === "shell-read" && n.status === "completed")!;
|
||||
if (failure === "missing-pending") f.notices.splice(f.notices.indexOf(start), 1);
|
||||
if (failure === "missing-terminal") f.notices.splice(f.notices.indexOf(end), 1);
|
||||
if (failure === "duplicate-pending") f.notices.push({ ...start, seq: 53 });
|
||||
if (failure === "duplicate-terminal") f.notices.push({ ...end, seq: 56 });
|
||||
if (failure === "extra-completed-only") f.notices.push({ ...end, toolCallId: "extra", seq: 57 });
|
||||
if (failure === "extra-complete-read") f.notices.push({ ...start, toolCallId: "extra", seq: 56 }, { ...end, toolCallId: "extra", seq: 57 });
|
||||
if (failure === "wrong-shell") start.shellId = "foreign";
|
||||
if (failure === "wrong-command") end.commandToolCallId = "foreign";
|
||||
if (failure === "failed") end.status = "failed";
|
||||
if (failure === "nonzero") end.exitCode = 1;
|
||||
if (failure === "reordered") start.seq = 14;
|
||||
if (failure === "late") end.seq = 61;
|
||||
if (failure === "wrong-operation") start.operation = "execute";
|
||||
if (failure === "mutation") end.target = "file.txt";
|
||||
if (failure === "read-path") start.readTargetSha256 = `sha256:${"a".repeat(64)}`;
|
||||
if (failure === "foreign-session") start.sessionId = "foreign";
|
||||
if (failure === "foreign-turn") start.turnId = "foreign";
|
||||
if (failure === "foreign-run") start.runId = "foreign";
|
||||
if (failure === "permission") start.stage = "permission_requested";
|
||||
if (failure === "semantic-fields") end.semanticCallIdentitySha256 = receipt.callIdentitySha256;
|
||||
if (failure === "normalized-semantic-field") end.semanticNormalizedInputSha256 = receipt.normalizedInputSha256;
|
||||
expect(onlyCopilotAttachedOperations(f.notices, f.command, f.proof)).toBe(false);
|
||||
});
|
||||
it("rejects duplicate shell origins and arbitrary extra operations", () => {
|
||||
const f = attachedFixture();
|
||||
const started = f.notices.find(n => n.shellState === "started")!;
|
||||
expect(onlyCopilotAttachedOperations([...f.notices, { ...started, seq: 16 }], f.command, f.proof)).toBe(false);
|
||||
for (const operation of [undefined, "read", "edit", "execute"] as const) expect(onlyCopilotAttachedOperations([...f.notices, { ...f.command, toolCallId: "extra", operation, seq: 17 }], f.command, f.proof)).toBe(false);
|
||||
});
|
||||
it("keeps actual flow correlation before no-extra-operation gate and keeps visible marker independent", async () => {
|
||||
const source = await readFile(new URL("./copilot-protection-flow.ts", import.meta.url), "utf8");
|
||||
expect(source.indexOf("readCopilotSemanticCompletion(runEvents")).toBeLessThan(source.indexOf('check("no-extra-native-operation", onlyCopilotAttachedOperations('));
|
||||
expect(source).toContain("undefined, runEvents), call!, semantic)");
|
||||
expect(source).toContain('check("exact-completion-marker", comments.filter');
|
||||
expect(source).not.toContain('if (remote) check("no-extra-native-operation"');
|
||||
});
|
||||
});
|
||||
|
||||
// Mirrors retained native order: command pending→shell started; finish; then
|
||||
// read_bash pending(shellId only)→completed(commandToolCallId), before turn end.
|
||||
function attachedFixture() {
|
||||
const f = fixture(); frame(f.rows[2]!).itemId = "3";
|
||||
for (const r of f.rows) { r.seq *= 10; if (frame(r).sourceKind === "runner") { r.sourceSeq *= 10; frame(r).sourceSeq *= 10; } }
|
||||
const fields = { operation: "execute", commandSha256: `sha256:${"a".repeat(64)}`, mode: "async", detach: false };
|
||||
f.rows[0] = notice(10, "command", "pending", fields);
|
||||
f.rows.push(notice(15, "command", "completed", { ...fields, shellId: "0", commandToolCallId: "command", shellState: "started" }),
|
||||
notice(52, "shell-read", "pending", { operation: "read", shellId: "0" }),
|
||||
notice(55, "shell-read", "completed", { operation: "read", shellId: "0", commandToolCallId: "command", shellState: "completed", exitCode: 0 }));
|
||||
const notices = readCopilotToolEvidence(f.rows, "run"), command = notices.find(n => n.seq === 10)!;
|
||||
const proof = readCopilotSemanticCompletion(f.rows, { ...f.expected, command });
|
||||
return { rows: f.rows, notices, command, proof };
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readCopilotToolEvidence, type CopilotToolNotice } from "./copilot-evidence.js";
|
||||
import { readCopilotContextRead, type CopilotContextReadProof } from "./copilot-context-evidence.js";
|
||||
|
||||
type Row = Record<string, any>;
|
||||
const rec = (v: unknown): Row => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Row : {};
|
||||
const id = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(v) && !v.includes("[REDACTED]");
|
||||
const hash = (v: string) => createHash("sha256").update(v).digest("hex");
|
||||
const canonical = (v: unknown): string => Array.isArray(v) ? `[${v.map(canonical).join(",")}]` : v !== null && typeof v === "object"
|
||||
? `{${Object.keys(v).sort().map(k => `${JSON.stringify(k)}:${canonical((v as Row)[k])}`).join(",")}}` : JSON.stringify(v) ?? "undefined";
|
||||
const digest = (v: unknown) => hash(canonical(v));
|
||||
const receiptKeys = ["callIdentitySha256", "inputSha256", "normalizedInputSha256", "operationId", "outcome", "resultSha256", "schema", "stage"].sort().join(",");
|
||||
export interface CopilotSemanticCompletionProof {
|
||||
schema: "paperclip.e2e.copilot-semantic-completion.v2" | "paperclip.e2e.copilot-semantic-completion.v4";
|
||||
contextRead?: CopilotContextReadProof;
|
||||
runId: string; turnId: string; nativeSessionId: string; normalizedSessionId: string; sourceInstanceId: string;
|
||||
nativeToolCallId: string; callIdentitySha256: string; inputSha256: string; normalizedInputSha256: string; resultSha256: string;
|
||||
proposedSeq: number; receiptSeq: number; nativeCompletedSeq: number; turnCompletedSeq: number; acceptedSeq: number;
|
||||
summarySha256: string;
|
||||
}
|
||||
/** Independent public-event oracle. A transport receipt alone never proves completion acceptance. */
|
||||
export function readCopilotSemanticCompletion(rows: readonly unknown[], expected: {
|
||||
companyId: string; runId: string; turnId: string; nativeSessionId: string; command: CopilotToolNotice; summary: string; requireContextRead?: boolean;
|
||||
}): CopilotSemanticCompletionProof {
|
||||
const fail = (message: string): never => { throw new Error(`Copilot semantic completion: ${message}`); };
|
||||
const all = rows.map(rec), notices = readCopilotToolEvidence(rows, expected.runId);
|
||||
const commandRows = all.filter(r => r.seq === expected.command.seq);
|
||||
if (commandRows.length !== 1) fail("missing exact command origin");
|
||||
const base = rec(rec(commandRows[0]!.payload).prpEvent);
|
||||
if (!id(base.normalizedSessionId) || !id(base.sourceInstanceId)) fail("missing command stream");
|
||||
function frame(r: Row, sourceKind: "runner" | "control_plane") {
|
||||
const e = rec(rec(r.payload).prpEvent);
|
||||
if (r.companyId !== expected.companyId || r.runId !== expected.runId || !Number.isSafeInteger(r.seq) || r.seq < 1
|
||||
|| e.schema !== "paperclip.prp.event.v1" || e.eventType !== r.eventType || e.sourceKind !== sourceKind
|
||||
|| e.runId !== expected.runId || e.turnId !== expected.turnId || e.normalizedSessionId !== base.normalizedSessionId
|
||||
|| !id(e.sourceInstanceId) || r.sourceInstanceId !== e.sourceInstanceId || r.sourceSeq !== e.sourceSeq
|
||||
|| !Number.isSafeInteger(e.sourceSeq) || e.sourceSeq < 1
|
||||
|| (sourceKind === "runner" && e.sourceInstanceId !== base.sourceInstanceId)) fail("foreign or malformed durable frame");
|
||||
return e;
|
||||
}
|
||||
frame(commandRows[0]!, "runner");
|
||||
// Every operation admitted later must belong to this durable stream, including
|
||||
// shell reads whose command identity is only known on their terminal notice.
|
||||
for (const n of notices) {
|
||||
const rs = all.filter(r => r.seq === n.seq);
|
||||
if (rs.length !== 1) fail("duplicate native row");
|
||||
frame(rs[0]!, "runner");
|
||||
}
|
||||
if (expected.command.runId !== expected.runId || expected.command.turnId !== expected.turnId || expected.command.sessionId !== expected.nativeSessionId) fail("foreign command");
|
||||
const contextRead = readCopilotContextRead(rows, expected.command, expected.requireContextRead);
|
||||
const authorityRows = all.filter(r => (rec(rec(rec(r.payload).prpEvent).payload).category === "paperclip_semantic_tool_receipt_v2" || rec(rec(rec(r.payload).prpEvent).payload).category === "paperclip_semantic_tool_receipt_v1") && ![contextRead?.receiptSeq, ...(contextRead?.discoveries.map(d => d.receiptSeq) ?? [])].includes(r.seq));
|
||||
// This authored case asks for one finish, not arbitrary semantic actions.
|
||||
if (authorityRows.length !== 1) fail("expected one authoritative finish receipt");
|
||||
const authorityRow = authorityRows[0]!, authority = frame(authorityRow, "runner"), payload = rec(authority.payload), origin = rec(payload.provenance);
|
||||
if (payload.category !== "paperclip_semantic_tool_receipt_v2" || payload.schema !== "paperclip.provider.notice.v1" || payload.scope !== "turn" || authorityRow.eventType !== "provider.notice.recorded"
|
||||
|| origin.method !== "paperclip/semantic_tool_result" || origin.eventType !== "semantic_result"
|
||||
|| origin.sessionId !== expected.nativeSessionId || origin.turnId !== expected.turnId || !Array.isArray(payload.details) || payload.details.length !== 8) fail("invalid receipt provenance");
|
||||
const fields: Record<string, string> = {};
|
||||
for (const d of payload.details) {
|
||||
if (Object.keys(rec(d)).sort().join(",") !== "name,value" || typeof d.name !== "string" || typeof d.value !== "string" || d.value.length > 256 || Object.hasOwn(fields, d.name)) fail("malformed receipt detail");
|
||||
fields[d.name] = d.value;
|
||||
}
|
||||
if (Object.keys(fields).sort().join(",") !== receiptKeys || fields.stage !== "semantic_result" || fields.schema !== "paperclip.semantic_tool_receipt.v2"
|
||||
|| fields.operationId !== "paperclip_finish" || fields.outcome !== "returned"
|
||||
|| ![fields.callIdentitySha256, fields.inputSha256, fields.normalizedInputSha256, fields.resultSha256].every(v => /^[a-f0-9]{64}$/.test(v!))) fail("invalid finish receipt");
|
||||
const matches = notices.filter(n => n.semanticCallIdentitySha256 === fields.callIdentitySha256);
|
||||
if (matches.length !== 1) fail("missing or duplicate native match");
|
||||
const native = matches[0]!;
|
||||
if (native.stage !== "tool" || native.status !== "completed" || native.sessionId !== expected.nativeSessionId || native.turnId !== expected.turnId
|
||||
|| native.semanticOperationId !== fields.operationId || native.semanticInputSha256 !== fields.inputSha256 || native.semanticNormalizedInputSha256 !== fields.normalizedInputSha256 || native.semanticResultSha256 !== fields.resultSha256 || native.semanticOutcome !== "returned") fail("native receipt mismatch");
|
||||
const group = notices.filter(n => n.toolCallId === native.toolCallId);
|
||||
const pending = group.filter(n => n.stage === "tool" && n.status === "pending");
|
||||
if (pending.length !== 1 || group.filter(n => n.status === "completed" || n.status === "failed").length !== 1
|
||||
|| group.some(n => n.stage !== "tool" || n.sessionId !== expected.nativeSessionId || n.turnId !== expected.turnId
|
||||
|| n.operation !== undefined || n.target !== undefined || n.commandSha256 !== undefined || n.shellId !== undefined || n.commandToolCallId !== undefined
|
||||
|| !["pending", "in_progress", "completed"].includes(n.status ?? "") || n.seq < pending[0]!.seq || n.seq > native.seq)) fail("not one semantic-only native lifecycle");
|
||||
for (const n of group) {
|
||||
const rs = all.filter(r => r.seq === n.seq); if (rs.length !== 1) fail("duplicate native row"); frame(rs[0]!, "runner");
|
||||
}
|
||||
const proposedRows = all.filter(r => r.eventType === "run.result.proposed"), acceptedRows = all.filter(r => r.eventType === "run.result.accepted"), terminals = all.filter(r => ["turn.completed", "turn.failed", "turn.cancelled", "turn.interrupted"].includes(r.eventType) && rec(rec(r.payload).prpEvent).turnId === expected.turnId);
|
||||
if (proposedRows.length !== 1 || acceptedRows.length !== 1 || terminals.length !== 1 || terminals[0]!.eventType !== "turn.completed"
|
||||
|| all.some(r => r.eventType === "run.result.rejected")) fail("missing, rejected or ambiguous completion");
|
||||
const proposed = frame(proposedRows[0]!, "runner"), accepted = frame(acceptedRows[0]!, "control_plane"), terminal = frame(terminals[0]!, "runner");
|
||||
const result = rec(proposed.payload), acceptedResult = rec(rec(accepted.payload).result);
|
||||
// Native correlation uses the invocation-captured callback call hash. The
|
||||
// canonical proposal itemId may instead identify the run: never interpret it
|
||||
// as a bridge call ID. Unique same-turn result bodies supply the independent
|
||||
// acceptance proof, joined to the normalized digest captured by the same
|
||||
// invocation after validation. Raw provider arguments may omit defaults;
|
||||
// their separate digest remains joined to the native receipt above. Never
|
||||
// reconstruct or normalize a proposal here to make its digest match.
|
||||
if (digest(result) !== fields.normalizedInputSha256
|
||||
|| result.schema !== "paperclip.run_result.v1" || result.reportedWorkDisposition !== "done" || result.summary !== expected.summary
|
||||
|| canonical(result) !== canonical(acceptedResult)) fail("canonical accepted result does not match exact finish");
|
||||
const pendingFrame = frame(all.find(r => r.seq === pending[0]!.seq)!, "runner");
|
||||
const completedFrame = frame(all.find(r => r.seq === native.seq)!, "runner");
|
||||
if (!(expected.command.seq < pending[0]!.seq && pending[0]!.seq < proposedRows[0]!.seq && proposedRows[0]!.seq < authorityRow.seq
|
||||
&& authorityRow.seq < native.seq && native.seq < terminals[0]!.seq && terminals[0]!.seq < acceptedRows[0]!.seq)
|
||||
|| !(base.sourceSeq < pendingFrame.sourceSeq && pendingFrame.sourceSeq < proposed.sourceSeq && proposed.sourceSeq < authority.sourceSeq
|
||||
&& authority.sourceSeq < completedFrame.sourceSeq && completedFrame.sourceSeq < terminal.sourceSeq)) fail("invalid causal order");
|
||||
return { schema: expected.requireContextRead ? "paperclip.e2e.copilot-semantic-completion.v4" : "paperclip.e2e.copilot-semantic-completion.v2", ...(contextRead ? { contextRead } : {}), runId: expected.runId, turnId: expected.turnId, nativeSessionId: expected.nativeSessionId,
|
||||
normalizedSessionId: base.normalizedSessionId, sourceInstanceId: base.sourceInstanceId, nativeToolCallId: native.toolCallId,
|
||||
callIdentitySha256: fields.callIdentitySha256!, inputSha256: fields.inputSha256!, normalizedInputSha256: fields.normalizedInputSha256!, resultSha256: fields.resultSha256!,
|
||||
proposedSeq: proposedRows[0]!.seq, receiptSeq: authorityRow.seq, nativeCompletedSeq: native.seq, turnCompletedSeq: terminals[0]!.seq, acceptedSeq: acceptedRows[0]!.seq, summarySha256: hash(expected.summary) };
|
||||
}
|
||||
|
||||
/** Called only after the completion proof and bootstrap-read oracle succeed. */
|
||||
export function onlyCopilotAttachedOperations(notices: readonly CopilotToolNotice[], command: CopilotToolNotice, proof: CopilotSemanticCompletionProof): boolean {
|
||||
if (proof.runId !== command.runId || proof.turnId !== command.turnId || proof.nativeSessionId !== command.sessionId
|
||||
|| proof.nativeToolCallId === command.toolCallId || notices.length > 2048
|
||||
|| notices.some(n => n.runId !== command.runId || n.sessionId !== command.sessionId || n.turnId !== command.turnId
|
||||
|| !Number.isSafeInteger(n.seq) || n.seq < 1 || n.seq >= proof.turnCompletedSeq)
|
||||
|| new Set(notices.map(n => n.seq)).size !== notices.length) return false;
|
||||
const groups = new Map<string, CopilotToolNotice[]>();
|
||||
for (const n of notices) { const group = groups.get(n.toolCallId) ?? []; group.push(n); groups.set(n.toolCallId, group); }
|
||||
if (proof.contextRead) {
|
||||
const context = groups.get(proof.contextRead.toolCallId);
|
||||
if (context && context.some(n => n.seq >= command.seq)) return false;
|
||||
groups.delete(proof.contextRead.toolCallId);
|
||||
for (const discovery of proof.contextRead.discoveries) {
|
||||
const group = groups.get(discovery.toolCallId);
|
||||
if (group && group.some(n => n.seq >= proof.contextRead!.pendingSeq)) return false;
|
||||
groups.delete(discovery.toolCallId);
|
||||
}
|
||||
}
|
||||
// Exactly one command, one shell-result read, and the already-proven finish.
|
||||
// A completed-only extra read cannot borrow the original command's identity.
|
||||
if (groups.size !== 3 || !groups.has(proof.nativeToolCallId)) return false;
|
||||
const complete = (group: CopilotToolNotice[]): boolean => {
|
||||
group.sort((a, b) => a.seq - b.seq);
|
||||
return group.length >= 2 && group[0]!.status === "pending" && group.at(-1)!.status === "completed"
|
||||
&& group.every((n, i) => n.stage === "tool" && (i === 0 ? n.status === "pending"
|
||||
: i === group.length - 1 ? n.status === "completed" : n.status === "in_progress"));
|
||||
};
|
||||
const semanticFree = (n: CopilotToolNotice) => n.semanticOperationId === undefined && n.semanticCallIdentitySha256 === undefined
|
||||
&& n.semanticInputSha256 === undefined && n.semanticNormalizedInputSha256 === undefined && n.semanticResultSha256 === undefined && n.semanticOutcome === undefined;
|
||||
const commandGroup = groups.get(command.toolCallId) ?? [];
|
||||
if (!complete(commandGroup) || commandGroup[0]!.seq !== command.seq) return false;
|
||||
const started = commandGroup.at(-1)!;
|
||||
if (!id(started.shellId) || started.shellState !== "started" || started.commandToolCallId !== command.toolCallId
|
||||
|| commandGroup.some(n => n.operation !== "execute" || n.commandSha256 !== command.commandSha256 || n.mode !== "async" || n.detach !== false
|
||||
|| n.target !== undefined || n.readTargetSha256 !== undefined || n.exitCode !== undefined || !semanticFree(n)
|
||||
|| (n !== started && (n.shellId !== undefined || n.shellState !== undefined || n.commandToolCallId !== undefined)))) return false;
|
||||
const shellGroup = [...groups].find(([key]) => key !== command.toolCallId && key !== proof.nativeToolCallId)![1];
|
||||
if (!complete(shellGroup)) return false;
|
||||
const terminal = shellGroup.at(-1)!;
|
||||
return shellGroup[0]!.seq > started.seq && terminal.seq < proof.turnCompletedSeq
|
||||
&& terminal.commandToolCallId === command.toolCallId && terminal.shellState === "completed" && terminal.exitCode === 0
|
||||
&& shellGroup.every(n => n.operation === "read" && n.shellId === started.shellId && n.target === undefined && n.readTargetSha256 === undefined
|
||||
&& n.commandSha256 === undefined && n.mode === undefined && n.detach === undefined && semanticFree(n)
|
||||
&& (n === terminal || (n.commandToolCallId === undefined && n.shellState === undefined && n.exitCode === undefined)));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { retainedMainlineStop } from "./copilot-stop-test-fixtures.js";
|
||||
import { assertCopilotStopAcknowledgement, readCopilotStopAcknowledgement } from "./copilot-stop-acknowledgement.js";
|
||||
|
||||
const fixtureCorrelationId = "db8b16c1-aafa-4662-9cf4-0bb317db7751";
|
||||
function fixture() {
|
||||
const run = structuredClone(retainedMainlineStop.run);
|
||||
const scope = { companyId: run.companyId, runId: run.id, issueId: run.nativeIssueId, callerUserId: "local-board" };
|
||||
const read = () => readCopilotStopAcknowledgement(run, scope, fixtureCorrelationId, "100", "101");
|
||||
return { run, scope, read };
|
||||
}
|
||||
describe("current-mainline Copilot Stop API acknowledgement", () => {
|
||||
it("calibrates against the retained failed attempt without requiring an unsupported caller-owned intent", () => {
|
||||
const f = fixture(), proof = f.read();
|
||||
expect(proof.intentId).not.toBe(`native-cancellation:${fixtureCorrelationId}`);
|
||||
expect(proof).toMatchObject({ fixtureCorrelationId, intentId: "native-cancellation:88c9dd8f-92d2-407d-a989-a3495e8e6944" });
|
||||
expect(() => assertCopilotStopAcknowledgement(proof, f.run, f.scope, fixtureCorrelationId, "100")).not.toThrow();
|
||||
expect(retainedMainlineStop.outcome).toContain("calibration only");
|
||||
});
|
||||
it.each([
|
||||
["foreign run", (r: any) => { r.id = "foreign"; }],
|
||||
["foreign company", (r: any) => { r.companyId = "foreign"; }],
|
||||
["foreign issue", (r: any) => { r.nativeIssueId = "foreign"; }],
|
||||
["failed run", (r: any) => { r.status = "failed"; }],
|
||||
["non-native run", (r: any) => { r.runtimeMode = "legacy"; }],
|
||||
["foreign API caller", (r: any) => { r.resultJson.cancelledByUserId = "other"; }],
|
||||
["agent caller", (r: any) => { r.resultJson.cancelledByActorType = "agent"; }],
|
||||
["foreign initiator", (r: any) => { r.resultJson.cancellation.initiator.id = "other"; }],
|
||||
["provider cancellation", (r: any) => { r.resultJson.cancellation.source = "provider"; }],
|
||||
["unexpected cancellation", (r: any) => { r.resultJson.cancellation.expected = false; }],
|
||||
["missing timestamp", (r: any) => { delete r.resultJson.startupCancellation.requestedAt; }],
|
||||
["startup-only cancellation", (r: any) => { r.resultJson.startupCancellation.beforeNativeSelection = true; }],
|
||||
["foreign acknowledgement", (r: any) => { r.resultJson.nativeCancellation.runId = "foreign"; }],
|
||||
["unacknowledged intent", (r: any) => { r.resultJson.nativeCancellation.dispatchState = "pending"; }],
|
||||
["undispatched intent", (r: any) => { r.resultJson.nativeCancellation.dispatched = false; }],
|
||||
["missing audit", (r: any) => { delete r.resultJson.nativeCancellation.intentAuditId; }],
|
||||
["same audit", (r: any) => { r.resultJson.nativeCancellation.intentAuditId = r.resultJson.nativeCancellation.acknowledgementAuditId; }],
|
||||
["unexpected effects", (r: any) => { r.resultJson.nativeCancellation.effects.push("replay"); }],
|
||||
["invalid intent", (r: any) => { r.resultJson.nativeCancellation.intentId = "native-cancellation:foreign"; }],
|
||||
] as const)("rejects %s", (_label, mutate) => { const f = fixture(); mutate(f.run); expect(f.read).toThrow(/acknowledgement/); });
|
||||
it.each(["100", "99", "0", "bad", "1".repeat(31)])("rejects response clock %s", clock => {
|
||||
const f = fixture(); expect(() => readCopilotStopAcknowledgement(f.run, f.scope, fixtureCorrelationId, "100", clock)).toThrow(/acknowledgement/);
|
||||
});
|
||||
it("requires retention of the actual response and rejects later replacement metadata", () => {
|
||||
const f = fixture(), proof = f.read();
|
||||
expect(() => assertCopilotStopAcknowledgement(undefined as any, f.run, f.scope, fixtureCorrelationId, "100")).toThrow(/not retained/);
|
||||
f.run.resultJson.nativeCancellation.intentId = "native-cancellation:11111111-2222-4333-8444-555555555555";
|
||||
expect(() => assertCopilotStopAcknowledgement(proof, f.run, f.scope, fixtureCorrelationId, "100")).toThrow(/changed/);
|
||||
});
|
||||
it("rejects a replayed API response from another fixture dispatch", () => {
|
||||
const f = fixture(), proof = f.read();
|
||||
expect(() => assertCopilotStopAcknowledgement(proof, f.run, f.scope, "11111111-2222-4333-8444-555555555555", "100")).toThrow(/changed/);
|
||||
expect(() => assertCopilotStopAcknowledgement(proof, f.run, f.scope, fixtureCorrelationId, "99")).toThrow(/changed/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { canonicalJson } from "../../packages/shared/src/portability-hash.js";
|
||||
|
||||
type Row = Record<string, any>;
|
||||
export interface CopilotStopScope { companyId: string; runId: string; issueId: string; callerUserId: string }
|
||||
export interface CopilotStopAcknowledgement extends CopilotStopScope {
|
||||
schema: "paperclip.e2e.copilot-stop-acknowledgement.v1";
|
||||
fixtureCorrelationId: string; dispatchMonotonicNs: string; apiResponseObservedMonotonicNs: string;
|
||||
intentId: string; intentAuditId: string; acknowledgementAuditId: string; responseMetadataSha256: string;
|
||||
}
|
||||
const rec = (v: unknown): Row => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Row : {};
|
||||
const id = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(v);
|
||||
const uuid = (v: unknown): v is string => typeof v === "string" && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/u.test(v);
|
||||
const mono = (v: unknown): v is string => typeof v === "string" && /^[1-9][0-9]{0,29}$/u.test(v);
|
||||
|
||||
/** Current mainline's board-only cancel API generates the intent itself. Record
|
||||
* its response after the retained pre-Stop observation; never invent support
|
||||
* for a caller-supplied cancellation UUID or infer a response from later rows. */
|
||||
export function readCopilotStopAcknowledgement(runInput: unknown, scope: CopilotStopScope, fixtureCorrelationId: string,
|
||||
dispatchMonotonicNs: string, apiResponseObservedMonotonicNs = process.hrtime.bigint().toString()): CopilotStopAcknowledgement {
|
||||
const invalid = () => new Error("Copilot Stop acknowledgement lacks exact current-mainline caller and scope");
|
||||
const run = rec(runInput), result = rec(run.resultJson), stop = rec(result.nativeCancellation), startup = rec(result.startupCancellation), cancellation = rec(result.cancellation);
|
||||
if (!Object.values(scope).every(id) || !uuid(fixtureCorrelationId) || !mono(dispatchMonotonicNs) || !mono(apiResponseObservedMonotonicNs)
|
||||
|| BigInt(apiResponseObservedMonotonicNs) <= BigInt(dispatchMonotonicNs)
|
||||
|| run.id !== scope.runId || run.companyId !== scope.companyId || run.nativeIssueId !== scope.issueId || run.runtimeMode !== "native" || run.status !== "cancelled"
|
||||
|| result.cancelledByActorType !== "user" || result.cancelledByUserId !== scope.callerUserId
|
||||
|| cancellation.source !== "operator" || cancellation.expected !== true || rec(cancellation.initiator).type !== "user" || rec(cancellation.initiator).id !== scope.callerUserId
|
||||
|| typeof startup.requestedAt !== "string" || !Number.isFinite(Date.parse(startup.requestedAt)) || startup.beforeNativeSelection !== false
|
||||
|| stop.schema !== "paperclip.native-cancellation.v1" || stop.runId !== scope.runId || stop.companyId !== scope.companyId || stop.issueId !== scope.issueId
|
||||
|| typeof stop.intentId !== "string" || !stop.intentId.startsWith("native-cancellation:") || !uuid(stop.intentId.slice("native-cancellation:".length))
|
||||
|| stop.scope !== "run" || stop.dispatched !== true || stop.dispatchState !== "acknowledged" || stop.reasonCode !== "cancellation_run_only"
|
||||
|| !Array.isArray(stop.effects) || stop.effects.length !== 1 || stop.effects[0] !== "release_run_resources"
|
||||
|| !id(stop.intentAuditId) || !id(stop.acknowledgementAuditId) || stop.intentAuditId === stop.acknowledgementAuditId) throw invalid();
|
||||
const metadata = { id: run.id, companyId: run.companyId, nativeIssueId: run.nativeIssueId, runtimeMode: run.runtimeMode, status: run.status,
|
||||
cancelledByActorType: result.cancelledByActorType, cancelledByUserId: result.cancelledByUserId, cancellation, startupCancellation: startup, nativeCancellation: stop };
|
||||
return { schema: "paperclip.e2e.copilot-stop-acknowledgement.v1", ...scope, fixtureCorrelationId, dispatchMonotonicNs, apiResponseObservedMonotonicNs,
|
||||
intentId: stop.intentId, intentAuditId: stop.intentAuditId, acknowledgementAuditId: stop.acknowledgementAuditId,
|
||||
responseMetadataSha256: `sha256:${createHash("sha256").update(canonicalJson(metadata)).digest("hex")}` };
|
||||
}
|
||||
|
||||
export function assertCopilotStopAcknowledgement(proof: CopilotStopAcknowledgement, run: unknown, scope: CopilotStopScope,
|
||||
fixtureCorrelationId: string, dispatchMonotonicNs: string): void {
|
||||
if (!proof || proof.schema !== "paperclip.e2e.copilot-stop-acknowledgement.v1") throw new Error("Copilot Stop acknowledgement response was not retained");
|
||||
const current = readCopilotStopAcknowledgement(run, scope, fixtureCorrelationId, dispatchMonotonicNs, proof.apiResponseObservedMonotonicNs);
|
||||
if (canonicalJson(proof) !== canonicalJson(current)) throw new Error("Copilot Stop acknowledgement changed after the API response");
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readCopilotStopAcknowledgement } from "./copilot-stop-acknowledgement.js";
|
||||
|
||||
// Synthetic scope/times over a retained, sanitized current-mainline API response.
|
||||
// This helper calibrates fixture assertions; it provides no live qualification.
|
||||
export const retainedMainlineStop = JSON.parse(readFileSync(new URL("./fixtures/copilot-mainline-stop-ack-v15.json", import.meta.url), "utf8"));
|
||||
export function applyMainlineStopMetadata(run: Record<string, any>) {
|
||||
const existing = run.resultJson ?? {}, template = structuredClone(retainedMainlineStop.run.resultJson);
|
||||
const requestedAt = existing.startupCancellation?.requestedAt ?? template.startupCancellation.requestedAt;
|
||||
run.runtimeMode = "native";
|
||||
run.resultJson = { ...existing, cancelledByActorType: "user", cancelledByUserId: "local-board",
|
||||
cancellation: { ...template.cancellation, recordedAt: requestedAt },
|
||||
startupCancellation: { requestedAt, beforeNativeSelection: false },
|
||||
nativeCancellation: { ...template.nativeCancellation, ...existing.nativeCancellation,
|
||||
intentId: template.nativeCancellation.intentId, companyId: run.companyId, runId: run.id, issueId: run.nativeIssueId } };
|
||||
}
|
||||
export function stopAcknowledgementForTest(run: Record<string, any>, fixtureCorrelationId: string, dispatchMonotonicNs: string) {
|
||||
return readCopilotStopAcknowledgement(run, { companyId: run.companyId, runId: run.id, issueId: run.nativeIssueId, callerUserId: "local-board" },
|
||||
fixtureCorrelationId, dispatchMonotonicNs, (BigInt(dispatchMonotonicNs) + 1n).toString());
|
||||
}
|
||||
@@ -39,6 +39,18 @@ export const DAYTONA_IMAGE_INPUT_PATHS = [
|
||||
"packages/paperclip-runner/scripts/build-provider-pack.mjs",
|
||||
"packages/paperclip-runner/scripts/build-node-startup-timeout.mjs",
|
||||
"packages/paperclip-runner/scripts/candidate-provider-pack.mjs",
|
||||
"packages/paperclip-runner/scripts/build-copilot-distribution.mjs",
|
||||
"packages/paperclip-runner/scripts/copilot-inner-distribution.mjs",
|
||||
"packages/paperclip-runner/scripts/copilot-profile-identity.mjs",
|
||||
"packages/paperclip-runner/scripts/materialize-copilot-binary.mjs",
|
||||
"packages/paperclip-runner/scripts/install-copilot-assets.mjs",
|
||||
"packages/paperclip-runner/test/fixtures/copilot-profile-v12-identity.json",
|
||||
"packages/paperclip-runner/test/fixtures/copilot-profile-v14-identity.json",
|
||||
"packages/paperclip-runner/test/fixtures/copilot-profile-v21-identity.json",
|
||||
"packages/paperclip-runner/test/fixtures/copilot-profile-v24-identity.json",
|
||||
"packages/paperclip-runner/test/fixtures/copilot-profile-v25-identity.json",
|
||||
"packages/paperclip-runner/test/fixtures/copilot-profile-v26-identity.json",
|
||||
"packages/paperclip-runner/test/fixtures/copilot-profile-v31-identity.json",
|
||||
"packages/paperclip-runner/scripts/materialize-cursor-distribution.mjs",
|
||||
"packages/paperclip-runner/scripts/cursor-runtime-patch.mjs",
|
||||
"packages/paperclip-runner/scripts/cursor-native-usage.mjs",
|
||||
|
||||
@@ -13,6 +13,29 @@ import {
|
||||
const repositoryRoot = path.resolve(import.meta.dirname, "../..");
|
||||
|
||||
describe("runner E2E Daytona image contract", () => {
|
||||
it("retains the active Copilot identity declaration in the Docker context", async () => {
|
||||
const [generator, dockerignore] = await Promise.all([
|
||||
readFile(path.join(repositoryRoot, "packages/paperclip-runner/scripts/copilot-profile-identity.mjs"), "utf8"),
|
||||
readFile(path.join(repositoryRoot, ".dockerignore"), "utf8"),
|
||||
]);
|
||||
const fixture = generator.match(/const fixture = resolve\(root, "([^"]+)"\)/)?.[1];
|
||||
expect(fixture).toBeDefined();
|
||||
const fixturePath = `packages/paperclip-runner/${fixture}`;
|
||||
expect(DAYTONA_IMAGE_INPUT_PATHS).toContain(fixturePath);
|
||||
await expect(readFile(path.join(repositoryRoot, fixturePath), "utf8")).resolves.toContain('"commandDigest"');
|
||||
// Docker's last matching rule wins, including the parent-directory exclusion.
|
||||
const rules = dockerignore.split(/\r?\n/).filter(rule => rule && !rule.startsWith("#"));
|
||||
const lastRule = rules.filter(rule => {
|
||||
const pattern = rule.startsWith("!") ? rule.slice(1) : rule;
|
||||
return path.matchesGlob(fixturePath, pattern) || fixturePath.startsWith(`${pattern}/`);
|
||||
}).at(-1);
|
||||
expect(lastRule?.startsWith("!")).toBe(true);
|
||||
// The exception also covers the next version without exposing other live evidence.
|
||||
const exception = lastRule!.slice(1);
|
||||
expect(path.matchesGlob("packages/paperclip-runner/test/fixtures/copilot-profile-v999-identity.json", exception)).toBe(true);
|
||||
expect(path.matchesGlob("packages/paperclip-runner/test/fixtures/copilot-live-denial-2026-09-28.json", exception)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the qualified native Grok binary separate from the legacy command", async () => {
|
||||
const [dockerfile, packBuilder, runnerPackage] = await Promise.all([
|
||||
readFile(path.join(repositoryRoot, "docker/daytona-runner/Dockerfile"), "utf8"),
|
||||
@@ -200,6 +223,18 @@ describe("runner E2E Daytona image contract", () => {
|
||||
"packages/paperclip-eval-kernel/src",
|
||||
"packages/paperclip-runner/package.json",
|
||||
"packages/paperclip-runner/scripts/candidate-provider-pack.mjs",
|
||||
"packages/paperclip-runner/scripts/build-copilot-distribution.mjs",
|
||||
"packages/paperclip-runner/scripts/copilot-inner-distribution.mjs",
|
||||
"packages/paperclip-runner/scripts/copilot-profile-identity.mjs",
|
||||
"packages/paperclip-runner/scripts/materialize-copilot-binary.mjs",
|
||||
"packages/paperclip-runner/scripts/install-copilot-assets.mjs",
|
||||
"packages/paperclip-runner/test/fixtures/copilot-profile-v12-identity.json",
|
||||
"packages/paperclip-runner/test/fixtures/copilot-profile-v14-identity.json",
|
||||
"packages/paperclip-runner/test/fixtures/copilot-profile-v21-identity.json",
|
||||
"packages/paperclip-runner/test/fixtures/copilot-profile-v24-identity.json",
|
||||
"packages/paperclip-runner/test/fixtures/copilot-profile-v25-identity.json",
|
||||
"packages/paperclip-runner/test/fixtures/copilot-profile-v26-identity.json",
|
||||
"packages/paperclip-runner/test/fixtures/copilot-profile-v31-identity.json",
|
||||
"packages/paperclip-runner/scripts/materialize-cursor-distribution.mjs",
|
||||
"packages/paperclip-runner/scripts/cursor-runtime-patch.mjs",
|
||||
"packages/paperclip-runner/cursor-distributions.json",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,296 @@
|
||||
{
|
||||
"schema": "paperclip.e2e.retained-copilot-context-discovery.v1",
|
||||
"sourceRevision": "90b14b00b7a56d48a6d203545d04753750f864d7",
|
||||
"retainedResult": "local-2026-10-03T22-19-31-840Z/copilot-protection/runner-acpx-copilot/local/native-permission-deny-write/attempt-1",
|
||||
"outcome": "failed; unanswered read-only discovery card; calibration only",
|
||||
"rows": [
|
||||
{
|
||||
"id": 38,
|
||||
"companyId": "578f9e01-20ff-4db1-885d-2671eafdcb50",
|
||||
"runId": "8f63e1b0-749c-483e-8f46-a1d112d27682",
|
||||
"agentId": "f74461b5-4959-46e7-8361-b7df67a15aa7",
|
||||
"seq": 38,
|
||||
"eventType": "runtime_request.created",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "8f63e1b0-749c-483e-8f46-a1d112d27682",
|
||||
"itemId": "item-8f63e1b0-749c-483e-8f46-a1d112d27682",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_e9c47d3e1de54cdc84bc319b205ad2bd",
|
||||
"payload": {
|
||||
"request": {
|
||||
"type": "permission",
|
||||
"itemId": "item-8f63e1b0-749c-483e-8f46-a1d112d27682",
|
||||
"method": "session/request_permission",
|
||||
"origin": {
|
||||
"method": "session/request_permission",
|
||||
"adapter": "acpx-runtime-sidecar",
|
||||
"provider": "acpx"
|
||||
},
|
||||
"prompt": "search_api",
|
||||
"schema": "paperclip.runtime_request.v2",
|
||||
"status": "pending",
|
||||
"turnId": "turn_lab_e9c47d3e1de54cdc84bc319b205ad2bd",
|
||||
"choices": [
|
||||
{
|
||||
"key": "accept",
|
||||
"label": "Allow once"
|
||||
},
|
||||
{
|
||||
"key": "accept_for_session",
|
||||
"label": "Allow for session"
|
||||
},
|
||||
{
|
||||
"key": "decline",
|
||||
"label": "Deny"
|
||||
},
|
||||
{
|
||||
"key": "cancel",
|
||||
"label": "Cancel"
|
||||
}
|
||||
],
|
||||
"details": {
|
||||
"itemId": "item-8f63e1b0-749c-483e-8f46-a1d112d27682",
|
||||
"origin": {
|
||||
"method": "session/request_permission",
|
||||
"adapter": "acpx-runtime-sidecar",
|
||||
"provider": "acpx"
|
||||
},
|
||||
"reason": "search_api",
|
||||
"turnId": "turn_lab_e9c47d3e1de54cdc84bc319b205ad2bd",
|
||||
"choices": [
|
||||
{
|
||||
"key": "accept",
|
||||
"label": "Allow once"
|
||||
},
|
||||
{
|
||||
"key": "accept_for_session",
|
||||
"label": "Allow for session"
|
||||
},
|
||||
{
|
||||
"key": "decline",
|
||||
"label": "Deny"
|
||||
},
|
||||
{
|
||||
"key": "cancel",
|
||||
"label": "Cancel"
|
||||
}
|
||||
],
|
||||
"threadId": "paperclip-a0dcc0a913a8deb4f50f5f0c0ceb407243221e79c368a416df3af9231902b1b3"
|
||||
},
|
||||
"requestId": "acpx-input-0074c32d9558181355984bac",
|
||||
"requestKind": "permission_approval"
|
||||
}
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-03T22:20:03.585Z",
|
||||
"eventType": "runtime_request.created",
|
||||
"sourceSeq": 15,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "7e5b6c31-f5d5-4edb-aa44-f18d8aa7c523:8f63e1b0-749c-483e-8f46-a1d112d27682:15",
|
||||
"sourceInstanceId": "7e5b6c31-f5d5-4edb-aa44-f18d8aa7c523",
|
||||
"normalizedSessionId": "fe6fb523-12e2-4f05-964d-ca0ff2b535e0"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "7e5b6c31-f5d5-4edb-aa44-f18d8aa7c523",
|
||||
"sourceEventId": "7e5b6c31-f5d5-4edb-aa44-f18d8aa7c523:8f63e1b0-749c-483e-8f46-a1d112d27682:15",
|
||||
"sourceSeq": 15,
|
||||
"sourcePayloadSha256": "42162563ef331950dbf0dab8738b4b954f5e8842b179504f9810ea0c691cf94e",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-03T22:20:03.594Z"
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"companyId": "578f9e01-20ff-4db1-885d-2671eafdcb50",
|
||||
"runId": "8f63e1b0-749c-483e-8f46-a1d112d27682",
|
||||
"agentId": "f74461b5-4959-46e7-8361-b7df67a15aa7",
|
||||
"seq": 39,
|
||||
"eventType": "provider.notice.recorded",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "8f63e1b0-749c-483e-8f46-a1d112d27682",
|
||||
"itemId": "item-8f63e1b0-749c-483e-8f46-a1d112d27682",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_e9c47d3e1de54cdc84bc319b205ad2bd",
|
||||
"payload": {
|
||||
"scope": "turn",
|
||||
"schema": "paperclip.provider.notice.v1",
|
||||
"details": [
|
||||
{
|
||||
"name": "stage",
|
||||
"value": "tool"
|
||||
},
|
||||
{
|
||||
"name": "toolCallId",
|
||||
"value": "call_5TMnOe8eC08O6s2DT93LNT6B"
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"value": "pending"
|
||||
}
|
||||
],
|
||||
"summary": "Copilot tool operation: pending.",
|
||||
"category": "copilot_tool_evidence_v1",
|
||||
"noticeId": "copilot-evidence-7f339408bfe222bbcdca74ee-1",
|
||||
"severity": "info",
|
||||
"provenance": {
|
||||
"method": "session/update",
|
||||
"turnId": "turn_lab_e9c47d3e1de54cdc84bc319b205ad2bd",
|
||||
"eventType": "tool",
|
||||
"sessionId": "54158a5d-0061-4aee-85f2-aa4d755ea54e"
|
||||
},
|
||||
"recoverable": true,
|
||||
"userActionable": false
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-03T22:20:03.585Z",
|
||||
"eventType": "provider.notice.recorded",
|
||||
"sourceSeq": 16,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "7e5b6c31-f5d5-4edb-aa44-f18d8aa7c523:8f63e1b0-749c-483e-8f46-a1d112d27682:16",
|
||||
"sourceInstanceId": "7e5b6c31-f5d5-4edb-aa44-f18d8aa7c523",
|
||||
"normalizedSessionId": "fe6fb523-12e2-4f05-964d-ca0ff2b535e0"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "7e5b6c31-f5d5-4edb-aa44-f18d8aa7c523",
|
||||
"sourceEventId": "7e5b6c31-f5d5-4edb-aa44-f18d8aa7c523:8f63e1b0-749c-483e-8f46-a1d112d27682:16",
|
||||
"sourceSeq": 16,
|
||||
"sourcePayloadSha256": "e93010406ca30fa0d533dd102c61cdf32d773ca8a4919d4b620ba41f2bb16b3f",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-03T22:20:03.601Z"
|
||||
},
|
||||
{
|
||||
"id": 40,
|
||||
"companyId": "578f9e01-20ff-4db1-885d-2671eafdcb50",
|
||||
"runId": "8f63e1b0-749c-483e-8f46-a1d112d27682",
|
||||
"agentId": "f74461b5-4959-46e7-8361-b7df67a15aa7",
|
||||
"seq": 40,
|
||||
"eventType": "tool.execution.started",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "8f63e1b0-749c-483e-8f46-a1d112d27682",
|
||||
"itemId": "item-8f63e1b0-749c-483e-8f46-a1d112d27682",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_e9c47d3e1de54cdc84bc319b205ad2bd",
|
||||
"payload": {
|
||||
"name": "paperclip-search_api",
|
||||
"output": "null",
|
||||
"schema": "paperclip.tool.execution.v1",
|
||||
"status": "running",
|
||||
"target": null,
|
||||
"exitCode": null,
|
||||
"progress": "paperclip-search_api (pending): get_task_context",
|
||||
"readOnly": true,
|
||||
"namespace": null,
|
||||
"operation": "search",
|
||||
"transport": "builtin",
|
||||
"durationMs": null,
|
||||
"executionId": "call_5TMnOe8eC08O6s2DT93LNT6B",
|
||||
"outputBytes": 4,
|
||||
"inputUpdated": true,
|
||||
"outputDigest": "sha256:74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b",
|
||||
"outputTruncated": false
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-03T22:20:03.585Z",
|
||||
"eventType": "tool.execution.started",
|
||||
"sourceSeq": 17,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "7e5b6c31-f5d5-4edb-aa44-f18d8aa7c523:8f63e1b0-749c-483e-8f46-a1d112d27682:17",
|
||||
"sourceInstanceId": "7e5b6c31-f5d5-4edb-aa44-f18d8aa7c523",
|
||||
"normalizedSessionId": "fe6fb523-12e2-4f05-964d-ca0ff2b535e0"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "7e5b6c31-f5d5-4edb-aa44-f18d8aa7c523",
|
||||
"sourceEventId": "7e5b6c31-f5d5-4edb-aa44-f18d8aa7c523:8f63e1b0-749c-483e-8f46-a1d112d27682:17",
|
||||
"sourceSeq": 17,
|
||||
"sourcePayloadSha256": "bea6445ec3425ba5aad574749cee68e17d0745a23d7037b8f29e1b4ccc7dd4a1",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-03T22:20:03.605Z"
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"companyId": "578f9e01-20ff-4db1-885d-2671eafdcb50",
|
||||
"runId": "8f63e1b0-749c-483e-8f46-a1d112d27682",
|
||||
"agentId": "f74461b5-4959-46e7-8361-b7df67a15aa7",
|
||||
"seq": 42,
|
||||
"eventType": "provider.notice.recorded",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "8f63e1b0-749c-483e-8f46-a1d112d27682",
|
||||
"itemId": "item-8f63e1b0-749c-483e-8f46-a1d112d27682",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_e9c47d3e1de54cdc84bc319b205ad2bd",
|
||||
"payload": {
|
||||
"scope": "turn",
|
||||
"schema": "paperclip.provider.notice.v1",
|
||||
"details": [
|
||||
{
|
||||
"name": "stage",
|
||||
"value": "permission_requested"
|
||||
},
|
||||
{
|
||||
"name": "toolCallId",
|
||||
"value": "call_5TMnOe8eC08O6s2DT93LNT6B"
|
||||
},
|
||||
{
|
||||
"name": "requestId",
|
||||
"value": "acpx-input-0074c32d9558181355984bac"
|
||||
},
|
||||
{
|
||||
"name": "declineOffered",
|
||||
"value": "true"
|
||||
}
|
||||
],
|
||||
"summary": "Copilot requested permission.",
|
||||
"category": "copilot_tool_evidence_v1",
|
||||
"noticeId": "copilot-evidence-7f339408bfe222bbcdca74ee-2",
|
||||
"severity": "info",
|
||||
"provenance": {
|
||||
"method": "session/request_permission",
|
||||
"turnId": "turn_lab_e9c47d3e1de54cdc84bc319b205ad2bd",
|
||||
"eventType": "permission_requested",
|
||||
"sessionId": "54158a5d-0061-4aee-85f2-aa4d755ea54e"
|
||||
},
|
||||
"recoverable": true,
|
||||
"userActionable": false
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-03T22:20:03.585Z",
|
||||
"eventType": "provider.notice.recorded",
|
||||
"sourceSeq": 18,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "7e5b6c31-f5d5-4edb-aa44-f18d8aa7c523:8f63e1b0-749c-483e-8f46-a1d112d27682:18",
|
||||
"sourceInstanceId": "7e5b6c31-f5d5-4edb-aa44-f18d8aa7c523",
|
||||
"normalizedSessionId": "fe6fb523-12e2-4f05-964d-ca0ff2b535e0"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "7e5b6c31-f5d5-4edb-aa44-f18d8aa7c523",
|
||||
"sourceEventId": "7e5b6c31-f5d5-4edb-aa44-f18d8aa7c523:8f63e1b0-749c-483e-8f46-a1d112d27682:18",
|
||||
"sourceSeq": 18,
|
||||
"sourcePayloadSha256": "adad7b07f2129c3ca09b3722fafc9ea5fc6b4ff7bdbc3525c71434b8a37eed0b",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-03T22:20:03.617Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
{
|
||||
"schema": "paperclip.e2e.retained-copilot-context-discovery.v1",
|
||||
"sourceRevision": "e00fe7ba5952533f6036f7abb883d110992889f8",
|
||||
"retainedResult": "tests/runner-e2e/results/local-2026-10-04T00-21-23-068Z/copilot-provider-death/runner-acpx-copilot/local/pending-permission-provider-death/attempt-1",
|
||||
"outcome": "failed before provider-death dispatch; unanswered read-only discovery card; calibration only",
|
||||
"rows": [
|
||||
{
|
||||
"id": 38,
|
||||
"companyId": "61e76084-01a0-4f2b-bfb7-fd5591473107",
|
||||
"runId": "975a7c7e-6a92-4907-afc7-aee73203f469",
|
||||
"agentId": "3a7f1016-cbea-4767-96a7-e0e6b6120345",
|
||||
"seq": 38,
|
||||
"eventType": "runtime_request.created",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "975a7c7e-6a92-4907-afc7-aee73203f469",
|
||||
"itemId": "item-975a7c7e-6a92-4907-afc7-aee73203f469",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_61222317c2304f13ab1e4fca31b8d578",
|
||||
"payload": {
|
||||
"request": {
|
||||
"type": "permission",
|
||||
"itemId": "item-975a7c7e-6a92-4907-afc7-aee73203f469",
|
||||
"method": "session/request_permission",
|
||||
"origin": {
|
||||
"method": "session/request_permission",
|
||||
"adapter": "acpx-runtime-sidecar",
|
||||
"provider": "acpx"
|
||||
},
|
||||
"prompt": "search_api",
|
||||
"schema": "paperclip.runtime_request.v2",
|
||||
"status": "pending",
|
||||
"turnId": "turn_lab_61222317c2304f13ab1e4fca31b8d578",
|
||||
"choices": [
|
||||
{
|
||||
"key": "accept",
|
||||
"label": "Allow once"
|
||||
},
|
||||
{
|
||||
"key": "accept_for_session",
|
||||
"label": "Allow for session"
|
||||
},
|
||||
{
|
||||
"key": "decline",
|
||||
"label": "Deny"
|
||||
},
|
||||
{
|
||||
"key": "cancel",
|
||||
"label": "Cancel"
|
||||
}
|
||||
],
|
||||
"details": {
|
||||
"itemId": "item-975a7c7e-6a92-4907-afc7-aee73203f469",
|
||||
"origin": {
|
||||
"method": "session/request_permission",
|
||||
"adapter": "acpx-runtime-sidecar",
|
||||
"provider": "acpx"
|
||||
},
|
||||
"reason": "search_api",
|
||||
"turnId": "turn_lab_61222317c2304f13ab1e4fca31b8d578",
|
||||
"choices": [
|
||||
{
|
||||
"key": "accept",
|
||||
"label": "Allow once"
|
||||
},
|
||||
{
|
||||
"key": "accept_for_session",
|
||||
"label": "Allow for session"
|
||||
},
|
||||
{
|
||||
"key": "decline",
|
||||
"label": "Deny"
|
||||
},
|
||||
{
|
||||
"key": "cancel",
|
||||
"label": "Cancel"
|
||||
}
|
||||
],
|
||||
"threadId": "paperclip-3dc0e2121b9b0232adf0e633c60d6190ef9047753210ca9a51ef02830a4af613"
|
||||
},
|
||||
"requestId": "acpx-input-5dab1f28c05b3e9ae25de1ec",
|
||||
"requestKind": "permission_approval"
|
||||
}
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-04T00:21:55.035Z",
|
||||
"eventType": "runtime_request.created",
|
||||
"sourceSeq": 15,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "51f1666e-be8e-4da4-9812-4cad3f37d9d9:975a7c7e-6a92-4907-afc7-aee73203f469:15",
|
||||
"sourceInstanceId": "51f1666e-be8e-4da4-9812-4cad3f37d9d9",
|
||||
"normalizedSessionId": "9aaa3f9d-8a7d-4d73-8704-b0efd500c4fc"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "51f1666e-be8e-4da4-9812-4cad3f37d9d9",
|
||||
"sourceEventId": "51f1666e-be8e-4da4-9812-4cad3f37d9d9:975a7c7e-6a92-4907-afc7-aee73203f469:15",
|
||||
"sourceSeq": 15,
|
||||
"sourcePayloadSha256": "717355680d3d00818585dab3af3f5b3a8c47871a9f1586f0287d9519c79ab742",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-04T00:21:55.043Z"
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"companyId": "61e76084-01a0-4f2b-bfb7-fd5591473107",
|
||||
"runId": "975a7c7e-6a92-4907-afc7-aee73203f469",
|
||||
"agentId": "3a7f1016-cbea-4767-96a7-e0e6b6120345",
|
||||
"seq": 39,
|
||||
"eventType": "provider.notice.recorded",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "975a7c7e-6a92-4907-afc7-aee73203f469",
|
||||
"itemId": "item-975a7c7e-6a92-4907-afc7-aee73203f469",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_61222317c2304f13ab1e4fca31b8d578",
|
||||
"payload": {
|
||||
"scope": "turn",
|
||||
"schema": "paperclip.provider.notice.v1",
|
||||
"details": [
|
||||
{
|
||||
"name": "stage",
|
||||
"value": "tool"
|
||||
},
|
||||
{
|
||||
"name": "toolCallId",
|
||||
"value": "call_fVtZbx73Gy42MZEv3TJroCQH"
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"value": "pending"
|
||||
}
|
||||
],
|
||||
"summary": "Copilot tool operation: pending.",
|
||||
"category": "copilot_tool_evidence_v1",
|
||||
"noticeId": "copilot-evidence-06be8b16b6645f5f69de0fb6-1",
|
||||
"severity": "info",
|
||||
"provenance": {
|
||||
"method": "session/update",
|
||||
"turnId": "turn_lab_61222317c2304f13ab1e4fca31b8d578",
|
||||
"eventType": "tool",
|
||||
"sessionId": "e735fdac-c742-4b28-8d36-02ee5f4cbec0"
|
||||
},
|
||||
"recoverable": true,
|
||||
"userActionable": false
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-04T00:21:55.035Z",
|
||||
"eventType": "provider.notice.recorded",
|
||||
"sourceSeq": 16,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "51f1666e-be8e-4da4-9812-4cad3f37d9d9:975a7c7e-6a92-4907-afc7-aee73203f469:16",
|
||||
"sourceInstanceId": "51f1666e-be8e-4da4-9812-4cad3f37d9d9",
|
||||
"normalizedSessionId": "9aaa3f9d-8a7d-4d73-8704-b0efd500c4fc"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "51f1666e-be8e-4da4-9812-4cad3f37d9d9",
|
||||
"sourceEventId": "51f1666e-be8e-4da4-9812-4cad3f37d9d9:975a7c7e-6a92-4907-afc7-aee73203f469:16",
|
||||
"sourceSeq": 16,
|
||||
"sourcePayloadSha256": "734364491d39e51a78538fc6d55e668d8660831427d7ba8dcdf1b2f4e753c126",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-04T00:21:55.047Z"
|
||||
},
|
||||
{
|
||||
"id": 40,
|
||||
"companyId": "61e76084-01a0-4f2b-bfb7-fd5591473107",
|
||||
"runId": "975a7c7e-6a92-4907-afc7-aee73203f469",
|
||||
"agentId": "3a7f1016-cbea-4767-96a7-e0e6b6120345",
|
||||
"seq": 40,
|
||||
"eventType": "tool.execution.started",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "975a7c7e-6a92-4907-afc7-aee73203f469",
|
||||
"itemId": "item-975a7c7e-6a92-4907-afc7-aee73203f469",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_61222317c2304f13ab1e4fca31b8d578",
|
||||
"payload": {
|
||||
"name": "paperclip-search_api",
|
||||
"output": "null",
|
||||
"schema": "paperclip.tool.execution.v1",
|
||||
"status": "running",
|
||||
"target": null,
|
||||
"exitCode": null,
|
||||
"progress": "paperclip-search_api (pending): get task context",
|
||||
"readOnly": true,
|
||||
"namespace": null,
|
||||
"operation": "search",
|
||||
"transport": "builtin",
|
||||
"durationMs": null,
|
||||
"executionId": "call_fVtZbx73Gy42MZEv3TJroCQH",
|
||||
"outputBytes": 4,
|
||||
"inputUpdated": true,
|
||||
"outputDigest": "sha256:74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b",
|
||||
"outputTruncated": false
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-04T00:21:55.035Z",
|
||||
"eventType": "tool.execution.started",
|
||||
"sourceSeq": 17,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "51f1666e-be8e-4da4-9812-4cad3f37d9d9:975a7c7e-6a92-4907-afc7-aee73203f469:17",
|
||||
"sourceInstanceId": "51f1666e-be8e-4da4-9812-4cad3f37d9d9",
|
||||
"normalizedSessionId": "9aaa3f9d-8a7d-4d73-8704-b0efd500c4fc"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "51f1666e-be8e-4da4-9812-4cad3f37d9d9",
|
||||
"sourceEventId": "51f1666e-be8e-4da4-9812-4cad3f37d9d9:975a7c7e-6a92-4907-afc7-aee73203f469:17",
|
||||
"sourceSeq": 17,
|
||||
"sourcePayloadSha256": "50614b7c76d807802cbc9a39afab04930c01129112a31c0647d1937eb752c282",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-04T00:21:55.050Z"
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"companyId": "61e76084-01a0-4f2b-bfb7-fd5591473107",
|
||||
"runId": "975a7c7e-6a92-4907-afc7-aee73203f469",
|
||||
"agentId": "3a7f1016-cbea-4767-96a7-e0e6b6120345",
|
||||
"seq": 42,
|
||||
"eventType": "provider.notice.recorded",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "975a7c7e-6a92-4907-afc7-aee73203f469",
|
||||
"itemId": "item-975a7c7e-6a92-4907-afc7-aee73203f469",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_61222317c2304f13ab1e4fca31b8d578",
|
||||
"payload": {
|
||||
"scope": "turn",
|
||||
"schema": "paperclip.provider.notice.v1",
|
||||
"details": [
|
||||
{
|
||||
"name": "stage",
|
||||
"value": "permission_requested"
|
||||
},
|
||||
{
|
||||
"name": "toolCallId",
|
||||
"value": "call_fVtZbx73Gy42MZEv3TJroCQH"
|
||||
},
|
||||
{
|
||||
"name": "requestId",
|
||||
"value": "acpx-input-5dab1f28c05b3e9ae25de1ec"
|
||||
},
|
||||
{
|
||||
"name": "declineOffered",
|
||||
"value": "true"
|
||||
}
|
||||
],
|
||||
"summary": "Copilot requested permission.",
|
||||
"category": "copilot_tool_evidence_v1",
|
||||
"noticeId": "copilot-evidence-06be8b16b6645f5f69de0fb6-2",
|
||||
"severity": "info",
|
||||
"provenance": {
|
||||
"method": "session/request_permission",
|
||||
"turnId": "turn_lab_61222317c2304f13ab1e4fca31b8d578",
|
||||
"eventType": "permission_requested",
|
||||
"sessionId": "e735fdac-c742-4b28-8d36-02ee5f4cbec0"
|
||||
},
|
||||
"recoverable": true,
|
||||
"userActionable": false
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-04T00:21:55.035Z",
|
||||
"eventType": "provider.notice.recorded",
|
||||
"sourceSeq": 18,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "51f1666e-be8e-4da4-9812-4cad3f37d9d9:975a7c7e-6a92-4907-afc7-aee73203f469:18",
|
||||
"sourceInstanceId": "51f1666e-be8e-4da4-9812-4cad3f37d9d9",
|
||||
"normalizedSessionId": "9aaa3f9d-8a7d-4d73-8704-b0efd500c4fc"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "51f1666e-be8e-4da4-9812-4cad3f37d9d9",
|
||||
"sourceEventId": "51f1666e-be8e-4da4-9812-4cad3f37d9d9:975a7c7e-6a92-4907-afc7-aee73203f469:18",
|
||||
"sourceSeq": 18,
|
||||
"sourcePayloadSha256": "7b6546f06f2ab8d6aeada545cca4b515ae387b98a1b05c93fefaeebf40737cad",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-04T00:21:55.061Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
{
|
||||
"schema": "paperclip.e2e.copilot-context-permission-fixture.v1",
|
||||
"provenance": {
|
||||
"source": "b41e8e2b6aa97a734ddfc363cd2466831a5b9895",
|
||||
"attempt": "local-2026-10-03T21-21-10-776Z",
|
||||
"originalGrade": "failed: timed out while initial context permission was unanswered",
|
||||
"selection": "Public permission and read-origin rows only; no reasoning or credentials."
|
||||
},
|
||||
"rows": [
|
||||
{
|
||||
"id": 38,
|
||||
"companyId": "acfabb15-ad54-40b3-b288-13ec06abe4b8",
|
||||
"runId": "34412568-43a8-47ed-8c91-91a2974562d2",
|
||||
"agentId": "efc5ae8f-9876-4c1a-bad0-21772e849d90",
|
||||
"seq": 38,
|
||||
"eventType": "runtime_request.created",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "34412568-43a8-47ed-8c91-91a2974562d2",
|
||||
"itemId": "item-34412568-43a8-47ed-8c91-91a2974562d2",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_e3bf9964656b47bdb71b6cf1ba78912d",
|
||||
"payload": {
|
||||
"request": {
|
||||
"type": "permission",
|
||||
"itemId": "item-34412568-43a8-47ed-8c91-91a2974562d2",
|
||||
"method": "session/request_permission",
|
||||
"origin": {
|
||||
"method": "session/request_permission",
|
||||
"adapter": "acpx-runtime-sidecar",
|
||||
"provider": "acpx"
|
||||
},
|
||||
"prompt": "get_task_context",
|
||||
"schema": "paperclip.runtime_request.v2",
|
||||
"status": "pending",
|
||||
"turnId": "turn_lab_e3bf9964656b47bdb71b6cf1ba78912d",
|
||||
"choices": [
|
||||
{
|
||||
"key": "accept",
|
||||
"label": "Allow once"
|
||||
},
|
||||
{
|
||||
"key": "accept_for_session",
|
||||
"label": "Allow for session"
|
||||
},
|
||||
{
|
||||
"key": "decline",
|
||||
"label": "Deny"
|
||||
},
|
||||
{
|
||||
"key": "cancel",
|
||||
"label": "Cancel"
|
||||
}
|
||||
],
|
||||
"details": {
|
||||
"itemId": "item-34412568-43a8-47ed-8c91-91a2974562d2",
|
||||
"origin": {
|
||||
"method": "session/request_permission",
|
||||
"adapter": "acpx-runtime-sidecar",
|
||||
"provider": "acpx"
|
||||
},
|
||||
"reason": "get_task_context",
|
||||
"turnId": "turn_lab_e3bf9964656b47bdb71b6cf1ba78912d",
|
||||
"choices": [
|
||||
{
|
||||
"key": "accept",
|
||||
"label": "Allow once"
|
||||
},
|
||||
{
|
||||
"key": "accept_for_session",
|
||||
"label": "Allow for session"
|
||||
},
|
||||
{
|
||||
"key": "decline",
|
||||
"label": "Deny"
|
||||
},
|
||||
{
|
||||
"key": "cancel",
|
||||
"label": "Cancel"
|
||||
}
|
||||
],
|
||||
"threadId": "paperclip-ad8774ab01e9c0d2f68bdd24378679c2ae71490f9bf41e3096d5708331765383"
|
||||
},
|
||||
"requestId": "acpx-input-77b887edc8c38070824692b3",
|
||||
"requestKind": "permission_approval"
|
||||
}
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-03T21:21:41.558Z",
|
||||
"eventType": "runtime_request.created",
|
||||
"sourceSeq": 15,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "59b8d1e1-cd19-447b-b60f-00a03acc4584:34412568-43a8-47ed-8c91-91a2974562d2:15",
|
||||
"sourceInstanceId": "59b8d1e1-cd19-447b-b60f-00a03acc4584",
|
||||
"normalizedSessionId": "8e0f95e7-8073-4453-b639-b9d5daf3d7c4"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "59b8d1e1-cd19-447b-b60f-00a03acc4584",
|
||||
"sourceEventId": "59b8d1e1-cd19-447b-b60f-00a03acc4584:34412568-43a8-47ed-8c91-91a2974562d2:15",
|
||||
"sourceSeq": 15,
|
||||
"sourcePayloadSha256": "f6b7ec5dc043205cfb96191a4bb7cb6067348bfee95201f62a01c6b9349815b4",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-03T21:21:41.571Z"
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"companyId": "acfabb15-ad54-40b3-b288-13ec06abe4b8",
|
||||
"runId": "34412568-43a8-47ed-8c91-91a2974562d2",
|
||||
"agentId": "efc5ae8f-9876-4c1a-bad0-21772e849d90",
|
||||
"seq": 39,
|
||||
"eventType": "provider.notice.recorded",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "34412568-43a8-47ed-8c91-91a2974562d2",
|
||||
"itemId": "item-34412568-43a8-47ed-8c91-91a2974562d2",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_e3bf9964656b47bdb71b6cf1ba78912d",
|
||||
"payload": {
|
||||
"scope": "turn",
|
||||
"schema": "paperclip.provider.notice.v1",
|
||||
"details": [
|
||||
{
|
||||
"name": "stage",
|
||||
"value": "tool"
|
||||
},
|
||||
{
|
||||
"name": "toolCallId",
|
||||
"value": "call_tIdO9gSPTCuzpKlMPlIa7kNT"
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"value": "pending"
|
||||
},
|
||||
{
|
||||
"name": "operation",
|
||||
"value": "read"
|
||||
}
|
||||
],
|
||||
"summary": "Copilot tool operation: pending.",
|
||||
"category": "copilot_tool_evidence_v1",
|
||||
"noticeId": "copilot-evidence-aee0303132a18925ffaebb35-1",
|
||||
"severity": "info",
|
||||
"provenance": {
|
||||
"method": "session/update",
|
||||
"turnId": "turn_lab_e3bf9964656b47bdb71b6cf1ba78912d",
|
||||
"eventType": "tool",
|
||||
"sessionId": "cd67831d-ce2e-4d4d-b16a-55ae787e707e"
|
||||
},
|
||||
"recoverable": true,
|
||||
"userActionable": false
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-03T21:21:41.558Z",
|
||||
"eventType": "provider.notice.recorded",
|
||||
"sourceSeq": 16,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "59b8d1e1-cd19-447b-b60f-00a03acc4584:34412568-43a8-47ed-8c91-91a2974562d2:16",
|
||||
"sourceInstanceId": "59b8d1e1-cd19-447b-b60f-00a03acc4584",
|
||||
"normalizedSessionId": "8e0f95e7-8073-4453-b639-b9d5daf3d7c4"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "59b8d1e1-cd19-447b-b60f-00a03acc4584",
|
||||
"sourceEventId": "59b8d1e1-cd19-447b-b60f-00a03acc4584:34412568-43a8-47ed-8c91-91a2974562d2:16",
|
||||
"sourceSeq": 16,
|
||||
"sourcePayloadSha256": "bae2a1d20364263627919f037a766ec72821b0a63b29f2fd59eeba82fb0624cb",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-03T21:21:41.575Z"
|
||||
},
|
||||
{
|
||||
"id": 40,
|
||||
"companyId": "acfabb15-ad54-40b3-b288-13ec06abe4b8",
|
||||
"runId": "34412568-43a8-47ed-8c91-91a2974562d2",
|
||||
"agentId": "efc5ae8f-9876-4c1a-bad0-21772e849d90",
|
||||
"seq": 40,
|
||||
"eventType": "tool.execution.started",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "34412568-43a8-47ed-8c91-91a2974562d2",
|
||||
"itemId": "item-34412568-43a8-47ed-8c91-91a2974562d2",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_e3bf9964656b47bdb71b6cf1ba78912d",
|
||||
"payload": {
|
||||
"name": "paperclip-get_task_context",
|
||||
"output": "null",
|
||||
"schema": "paperclip.tool.execution.v1",
|
||||
"status": "running",
|
||||
"target": null,
|
||||
"exitCode": null,
|
||||
"progress": "paperclip-get_task_context (pending)",
|
||||
"readOnly": true,
|
||||
"namespace": null,
|
||||
"operation": "read",
|
||||
"transport": "builtin",
|
||||
"durationMs": null,
|
||||
"executionId": "call_tIdO9gSPTCuzpKlMPlIa7kNT",
|
||||
"outputBytes": 4,
|
||||
"inputUpdated": true,
|
||||
"outputDigest": "sha256:74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b",
|
||||
"outputTruncated": false
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-03T21:21:41.558Z",
|
||||
"eventType": "tool.execution.started",
|
||||
"sourceSeq": 17,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "59b8d1e1-cd19-447b-b60f-00a03acc4584:34412568-43a8-47ed-8c91-91a2974562d2:17",
|
||||
"sourceInstanceId": "59b8d1e1-cd19-447b-b60f-00a03acc4584",
|
||||
"normalizedSessionId": "8e0f95e7-8073-4453-b639-b9d5daf3d7c4"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "59b8d1e1-cd19-447b-b60f-00a03acc4584",
|
||||
"sourceEventId": "59b8d1e1-cd19-447b-b60f-00a03acc4584:34412568-43a8-47ed-8c91-91a2974562d2:17",
|
||||
"sourceSeq": 17,
|
||||
"sourcePayloadSha256": "6e895fbc42b0858d1f0cc354ea6f32e93c75584ce687d1ed53c43ba69e08beee",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-03T21:21:41.578Z"
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"companyId": "acfabb15-ad54-40b3-b288-13ec06abe4b8",
|
||||
"runId": "34412568-43a8-47ed-8c91-91a2974562d2",
|
||||
"agentId": "efc5ae8f-9876-4c1a-bad0-21772e849d90",
|
||||
"seq": 42,
|
||||
"eventType": "provider.notice.recorded",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "34412568-43a8-47ed-8c91-91a2974562d2",
|
||||
"itemId": "item-34412568-43a8-47ed-8c91-91a2974562d2",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_e3bf9964656b47bdb71b6cf1ba78912d",
|
||||
"payload": {
|
||||
"scope": "turn",
|
||||
"schema": "paperclip.provider.notice.v1",
|
||||
"details": [
|
||||
{
|
||||
"name": "stage",
|
||||
"value": "permission_requested"
|
||||
},
|
||||
{
|
||||
"name": "toolCallId",
|
||||
"value": "call_tIdO9gSPTCuzpKlMPlIa7kNT"
|
||||
},
|
||||
{
|
||||
"name": "requestId",
|
||||
"value": "acpx-input-77b887edc8c38070824692b3"
|
||||
},
|
||||
{
|
||||
"name": "declineOffered",
|
||||
"value": "true"
|
||||
}
|
||||
],
|
||||
"summary": "Copilot requested permission.",
|
||||
"category": "copilot_tool_evidence_v1",
|
||||
"noticeId": "copilot-evidence-aee0303132a18925ffaebb35-2",
|
||||
"severity": "info",
|
||||
"provenance": {
|
||||
"method": "session/request_permission",
|
||||
"turnId": "turn_lab_e3bf9964656b47bdb71b6cf1ba78912d",
|
||||
"eventType": "permission_requested",
|
||||
"sessionId": "cd67831d-ce2e-4d4d-b16a-55ae787e707e"
|
||||
},
|
||||
"recoverable": true,
|
||||
"userActionable": false
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-03T21:21:41.558Z",
|
||||
"eventType": "provider.notice.recorded",
|
||||
"sourceSeq": 18,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "59b8d1e1-cd19-447b-b60f-00a03acc4584:34412568-43a8-47ed-8c91-91a2974562d2:18",
|
||||
"sourceInstanceId": "59b8d1e1-cd19-447b-b60f-00a03acc4584",
|
||||
"normalizedSessionId": "8e0f95e7-8073-4453-b639-b9d5daf3d7c4"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "59b8d1e1-cd19-447b-b60f-00a03acc4584",
|
||||
"sourceEventId": "59b8d1e1-cd19-447b-b60f-00a03acc4584:34412568-43a8-47ed-8c91-91a2974562d2:18",
|
||||
"sourceSeq": 18,
|
||||
"sourcePayloadSha256": "61bbc7742a510fe8a62a795b1c16a9ea3b481467d88ebbdff55ea648ff865f35",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-03T21:21:41.588Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
{
|
||||
"schema": "paperclip.e2e.copilot-context-permission-fixture.v1",
|
||||
"provenance": {
|
||||
"source": "f43b483517f952121c426cc58e6fb4c361577602",
|
||||
"runtimeSource": "3073c062d47891cc275072de4eaaf049f5837839",
|
||||
"attempt": "copilot-v26-local-denial-01",
|
||||
"originalGrade": "failed: current sidecar Copilot origin rejected by historical context fixture",
|
||||
"selection": "Exact public context permission and read-origin rows only; no model text or credentials.",
|
||||
"apiStateSha256": "9d8981ee034c07744c87894e8939bdd7ed3a4882fc2954dd7bbbdd78f702e6cd"
|
||||
},
|
||||
"rows": [
|
||||
{
|
||||
"id": 38,
|
||||
"companyId": "22f78234-c769-4fd7-b216-99dc12f088f2",
|
||||
"runId": "5c1ddc98-5f3c-4469-ad1b-ae825af1fa44",
|
||||
"agentId": "ad45a4b4-067a-4c59-94de-db99c4674c54",
|
||||
"seq": 38,
|
||||
"eventType": "provider.notice.recorded",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "5c1ddc98-5f3c-4469-ad1b-ae825af1fa44",
|
||||
"itemId": "item-5c1ddc98-5f3c-4469-ad1b-ae825af1fa44",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_73aaed4624a44a448bd3d3fe0d0bdb8a",
|
||||
"payload": {
|
||||
"scope": "turn",
|
||||
"schema": "paperclip.provider.notice.v1",
|
||||
"details": [
|
||||
{
|
||||
"name": "stage",
|
||||
"value": "tool"
|
||||
},
|
||||
{
|
||||
"name": "toolCallId",
|
||||
"value": "call_ZURUpVXGfBtUt9Ev2V9mqCyN"
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"value": "pending"
|
||||
},
|
||||
{
|
||||
"name": "operation",
|
||||
"value": "read"
|
||||
}
|
||||
],
|
||||
"summary": "Copilot tool operation: pending.",
|
||||
"category": "copilot_tool_evidence_v1",
|
||||
"noticeId": "copilot-evidence-550fe5ba6f88ad9c180bb3a6-1",
|
||||
"severity": "info",
|
||||
"provenance": {
|
||||
"method": "session/update",
|
||||
"turnId": "turn_lab_73aaed4624a44a448bd3d3fe0d0bdb8a",
|
||||
"eventType": "tool",
|
||||
"sessionId": "bf102626-34e9-4597-b440-6ccb851fc6e0"
|
||||
},
|
||||
"recoverable": true,
|
||||
"userActionable": false
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-08T00:41:20.073Z",
|
||||
"eventType": "provider.notice.recorded",
|
||||
"sourceSeq": 15,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "70076d7e-5eed-4d98-869b-44a49e167abf:5c1ddc98-5f3c-4469-ad1b-ae825af1fa44:15",
|
||||
"sourceInstanceId": "70076d7e-5eed-4d98-869b-44a49e167abf",
|
||||
"normalizedSessionId": "ad5649d3-696e-45d4-8910-8ddb61439340"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "70076d7e-5eed-4d98-869b-44a49e167abf",
|
||||
"sourceEventId": "70076d7e-5eed-4d98-869b-44a49e167abf:5c1ddc98-5f3c-4469-ad1b-ae825af1fa44:15",
|
||||
"sourceSeq": 15,
|
||||
"sourcePayloadSha256": "b829c1d45c49fa61cd0143fa224095b0cf79fdae4b94372496f87c7c1f5e7eb6",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-08T00:41:20.086Z"
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"companyId": "22f78234-c769-4fd7-b216-99dc12f088f2",
|
||||
"runId": "5c1ddc98-5f3c-4469-ad1b-ae825af1fa44",
|
||||
"agentId": "ad45a4b4-067a-4c59-94de-db99c4674c54",
|
||||
"seq": 39,
|
||||
"eventType": "tool.execution.started",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "5c1ddc98-5f3c-4469-ad1b-ae825af1fa44",
|
||||
"itemId": "item-5c1ddc98-5f3c-4469-ad1b-ae825af1fa44",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_73aaed4624a44a448bd3d3fe0d0bdb8a",
|
||||
"payload": {
|
||||
"name": "paperclip-get_task_context",
|
||||
"output": "null",
|
||||
"schema": "paperclip.tool.execution.v1",
|
||||
"status": "running",
|
||||
"target": null,
|
||||
"exitCode": null,
|
||||
"progress": "paperclip-get_task_context (pending)",
|
||||
"readOnly": true,
|
||||
"namespace": null,
|
||||
"operation": "read",
|
||||
"transport": "builtin",
|
||||
"durationMs": null,
|
||||
"executionId": "call_ZURUpVXGfBtUt9Ev2V9mqCyN",
|
||||
"outputBytes": 4,
|
||||
"inputUpdated": true,
|
||||
"outputDigest": "sha256:74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b",
|
||||
"outputTruncated": false
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-08T00:41:20.086Z",
|
||||
"eventType": "tool.execution.started",
|
||||
"sourceSeq": 16,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "70076d7e-5eed-4d98-869b-44a49e167abf:5c1ddc98-5f3c-4469-ad1b-ae825af1fa44:16",
|
||||
"sourceInstanceId": "70076d7e-5eed-4d98-869b-44a49e167abf",
|
||||
"normalizedSessionId": "ad5649d3-696e-45d4-8910-8ddb61439340"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "70076d7e-5eed-4d98-869b-44a49e167abf",
|
||||
"sourceEventId": "70076d7e-5eed-4d98-869b-44a49e167abf:5c1ddc98-5f3c-4469-ad1b-ae825af1fa44:16",
|
||||
"sourceSeq": 16,
|
||||
"sourcePayloadSha256": "05a9ff0a2e6e2708604dc44ad4516ce82880c8bb0b50b92c73f0d1fe4c2af503",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-08T00:41:20.094Z"
|
||||
},
|
||||
{
|
||||
"id": 41,
|
||||
"companyId": "22f78234-c769-4fd7-b216-99dc12f088f2",
|
||||
"runId": "5c1ddc98-5f3c-4469-ad1b-ae825af1fa44",
|
||||
"agentId": "ad45a4b4-067a-4c59-94de-db99c4674c54",
|
||||
"seq": 41,
|
||||
"eventType": "provider.notice.recorded",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "5c1ddc98-5f3c-4469-ad1b-ae825af1fa44",
|
||||
"itemId": "item-5c1ddc98-5f3c-4469-ad1b-ae825af1fa44",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_73aaed4624a44a448bd3d3fe0d0bdb8a",
|
||||
"payload": {
|
||||
"scope": "turn",
|
||||
"schema": "paperclip.provider.notice.v1",
|
||||
"details": [
|
||||
{
|
||||
"name": "stage",
|
||||
"value": "permission_requested"
|
||||
},
|
||||
{
|
||||
"name": "toolCallId",
|
||||
"value": "call_ZURUpVXGfBtUt9Ev2V9mqCyN"
|
||||
},
|
||||
{
|
||||
"name": "requestId",
|
||||
"value": "acpx-input-7ea2516111a915df66eb8c03"
|
||||
},
|
||||
{
|
||||
"name": "declineOffered",
|
||||
"value": "true"
|
||||
}
|
||||
],
|
||||
"summary": "Copilot requested permission.",
|
||||
"category": "copilot_tool_evidence_v1",
|
||||
"noticeId": "copilot-evidence-550fe5ba6f88ad9c180bb3a6-2",
|
||||
"severity": "info",
|
||||
"provenance": {
|
||||
"method": "session/request_permission",
|
||||
"turnId": "turn_lab_73aaed4624a44a448bd3d3fe0d0bdb8a",
|
||||
"eventType": "permission_requested",
|
||||
"sessionId": "bf102626-34e9-4597-b440-6ccb851fc6e0"
|
||||
},
|
||||
"recoverable": true,
|
||||
"userActionable": false
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-08T00:41:20.086Z",
|
||||
"eventType": "provider.notice.recorded",
|
||||
"sourceSeq": 17,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "70076d7e-5eed-4d98-869b-44a49e167abf:5c1ddc98-5f3c-4469-ad1b-ae825af1fa44:17",
|
||||
"sourceInstanceId": "70076d7e-5eed-4d98-869b-44a49e167abf",
|
||||
"normalizedSessionId": "ad5649d3-696e-45d4-8910-8ddb61439340"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "70076d7e-5eed-4d98-869b-44a49e167abf",
|
||||
"sourceEventId": "70076d7e-5eed-4d98-869b-44a49e167abf:5c1ddc98-5f3c-4469-ad1b-ae825af1fa44:17",
|
||||
"sourceSeq": 17,
|
||||
"sourcePayloadSha256": "8142d6ca2b08865eda6b2eef8a721973ccfec164dc5546d941e185fb9c9391a8",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-08T00:41:20.129Z"
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"companyId": "22f78234-c769-4fd7-b216-99dc12f088f2",
|
||||
"runId": "5c1ddc98-5f3c-4469-ad1b-ae825af1fa44",
|
||||
"agentId": "ad45a4b4-067a-4c59-94de-db99c4674c54",
|
||||
"seq": 42,
|
||||
"eventType": "runtime_request.created",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "5c1ddc98-5f3c-4469-ad1b-ae825af1fa44",
|
||||
"itemId": "item-5c1ddc98-5f3c-4469-ad1b-ae825af1fa44",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_73aaed4624a44a448bd3d3fe0d0bdb8a",
|
||||
"payload": {
|
||||
"request": {
|
||||
"type": "permission",
|
||||
"itemId": "item-5c1ddc98-5f3c-4469-ad1b-ae825af1fa44",
|
||||
"method": "session/request_permission",
|
||||
"origin": {
|
||||
"method": "session/request_permission",
|
||||
"adapter": "acpx-runtime-sidecar",
|
||||
"provider": "copilot"
|
||||
},
|
||||
"prompt": "get_task_context",
|
||||
"schema": "paperclip.runtime_request.v2",
|
||||
"status": "pending",
|
||||
"turnId": "turn_lab_73aaed4624a44a448bd3d3fe0d0bdb8a",
|
||||
"choices": [
|
||||
{
|
||||
"key": "accept",
|
||||
"label": "Allow once"
|
||||
},
|
||||
{
|
||||
"key": "accept_for_session",
|
||||
"label": "Allow for session"
|
||||
},
|
||||
{
|
||||
"key": "decline",
|
||||
"label": "Deny"
|
||||
},
|
||||
{
|
||||
"key": "cancel",
|
||||
"label": "Cancel"
|
||||
}
|
||||
],
|
||||
"details": {
|
||||
"itemId": "item-5c1ddc98-5f3c-4469-ad1b-ae825af1fa44",
|
||||
"origin": {
|
||||
"method": "session/request_permission",
|
||||
"adapter": "acpx-runtime-sidecar",
|
||||
"provider": "copilot"
|
||||
},
|
||||
"reason": "get_task_context",
|
||||
"turnId": "turn_lab_73aaed4624a44a448bd3d3fe0d0bdb8a",
|
||||
"choices": [
|
||||
{
|
||||
"key": "accept",
|
||||
"label": "Allow once"
|
||||
},
|
||||
{
|
||||
"key": "accept_for_session",
|
||||
"label": "Allow for session"
|
||||
},
|
||||
{
|
||||
"key": "decline",
|
||||
"label": "Deny"
|
||||
},
|
||||
{
|
||||
"key": "cancel",
|
||||
"label": "Cancel"
|
||||
}
|
||||
],
|
||||
"threadId": "paperclip-80e180f467cfbdfe68e90384027c4b1fe0f65dc94991b7f6a67321043b66f912",
|
||||
"toolCallId": "call_ZURUpVXGfBtUt9Ev2V9mqCyN"
|
||||
},
|
||||
"requestId": "acpx-input-7ea2516111a915df66eb8c03",
|
||||
"requestKind": "permission_approval"
|
||||
}
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-08T00:41:20.088Z",
|
||||
"eventType": "runtime_request.created",
|
||||
"sourceSeq": 18,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "70076d7e-5eed-4d98-869b-44a49e167abf:5c1ddc98-5f3c-4469-ad1b-ae825af1fa44:18",
|
||||
"sourceInstanceId": "70076d7e-5eed-4d98-869b-44a49e167abf",
|
||||
"normalizedSessionId": "ad5649d3-696e-45d4-8910-8ddb61439340"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "70076d7e-5eed-4d98-869b-44a49e167abf",
|
||||
"sourceEventId": "70076d7e-5eed-4d98-869b-44a49e167abf:5c1ddc98-5f3c-4469-ad1b-ae825af1fa44:18",
|
||||
"sourceSeq": 18,
|
||||
"sourcePayloadSha256": "a1705b655a8c7831a2458b74a3d433d0948e14bb8494f174c7b43afaa98bdff1",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-08T00:41:20.138Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"schema": "paperclip.e2e.retained-mainline-stop.v1",
|
||||
"sourceRevision": "2dd62ad52b8b4348d286c59e11e36144305bca6c",
|
||||
"outcome": "failed fixture expected unsupported caller-owned cancellation ID; calibration only",
|
||||
"run": {
|
||||
"id": "be098444-7780-4afc-976a-4cfbc9565eb2",
|
||||
"companyId": "045e3eb6-1f10-47dd-9931-7f4e0396d682",
|
||||
"nativeIssueId": "7f425402-9ec8-4011-9e37-6f6d4d3a7153",
|
||||
"runtimeMode": "native",
|
||||
"status": "cancelled",
|
||||
"resultJson": {
|
||||
"stopReason": "cancelled",
|
||||
"cancellation": {
|
||||
"reason": "Cancelled by a board operator",
|
||||
"source": "operator",
|
||||
"expected": true,
|
||||
"initiator": {
|
||||
"id": "local-board",
|
||||
"type": "user"
|
||||
},
|
||||
"recordedAt": "2026-10-03T22:42:57.243Z"
|
||||
},
|
||||
"timeoutFired": false,
|
||||
"timeoutSource": "config",
|
||||
"cancelledByUserId": "local-board",
|
||||
"timeoutConfigured": true,
|
||||
"nativeCancellation": {
|
||||
"runId": "be098444-7780-4afc-976a-4cfbc9565eb2",
|
||||
"scope": "run",
|
||||
"schema": "paperclip.native-cancellation.v1",
|
||||
"effects": [
|
||||
"release_run_resources"
|
||||
],
|
||||
"issueId": "7f425402-9ec8-4011-9e37-6f6d4d3a7153",
|
||||
"intentId": "native-cancellation:88c9dd8f-92d2-407d-a989-a3495e8e6944",
|
||||
"companyId": "045e3eb6-1f10-47dd-9931-7f4e0396d682",
|
||||
"decisionId": null,
|
||||
"dispatched": true,
|
||||
"reasonCode": "cancellation_run_only",
|
||||
"recordedAt": "2026-10-03T22:42:57.247Z",
|
||||
"dispatchState": "acknowledged",
|
||||
"intentAuditId": "ceab3cf4-3bec-4133-a73b-15101f3035b2",
|
||||
"acknowledgedAt": "2026-10-03T22:42:57.249Z",
|
||||
"acknowledgementAuditId": "87b5cd2d-6c5e-4beb-91e7-3392d0868036",
|
||||
"priorCoordinatorDecisionId": null
|
||||
},
|
||||
"effectiveTimeoutSec": 120,
|
||||
"startupCancellation": {
|
||||
"requestedAt": "2026-10-03T22:42:57.243Z",
|
||||
"beforeNativeSelection": false
|
||||
},
|
||||
"cancelledByActorType": "user"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,964 @@
|
||||
{
|
||||
"schema": "paperclip.e2e.retained-patch-permission.v1",
|
||||
"sourceRevision": "7f6238747165ba2b420fff48ec3a71f6ca594f21",
|
||||
"outcome": "failed fixture required notification path and redundant card ID; sanitized pre-Stop calibration only",
|
||||
"scope": {
|
||||
"provider": "copilot",
|
||||
"companyId": "6ce27423-9b7a-4423-9171-8d4d40e6c346",
|
||||
"issueId": "af0fd9f5-9bdd-42f4-a214-328919e315f7",
|
||||
"runId": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"target": "pc-denied-dUYoVS/active-stop-80fcac4754d6-1.txt",
|
||||
"requireContextRead": true
|
||||
},
|
||||
"rows": [
|
||||
{
|
||||
"id": 119,
|
||||
"companyId": "6ce27423-9b7a-4423-9171-8d4d40e6c346",
|
||||
"runId": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"agentId": "1d6760b4-ebe7-4198-9f3d-5d4568b997c6",
|
||||
"seq": 119,
|
||||
"eventType": "runtime_request.created",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"itemId": "item-861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_71c34ac179ea42ac9e5a65138c15e2b7",
|
||||
"payload": {
|
||||
"request": {
|
||||
"type": "permission",
|
||||
"itemId": "item-861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"method": "session/request_permission",
|
||||
"origin": {
|
||||
"method": "session/request_permission",
|
||||
"adapter": "acpx-runtime-sidecar",
|
||||
"provider": "acpx"
|
||||
},
|
||||
"prompt": "get_task_context",
|
||||
"schema": "paperclip.runtime_request.v2",
|
||||
"status": "pending",
|
||||
"turnId": "turn_lab_71c34ac179ea42ac9e5a65138c15e2b7",
|
||||
"choices": [
|
||||
{
|
||||
"key": "accept",
|
||||
"label": "Allow once"
|
||||
},
|
||||
{
|
||||
"key": "accept_for_session",
|
||||
"label": "Allow for session"
|
||||
},
|
||||
{
|
||||
"key": "decline",
|
||||
"label": "Deny"
|
||||
},
|
||||
{
|
||||
"key": "cancel",
|
||||
"label": "Cancel"
|
||||
}
|
||||
],
|
||||
"details": {
|
||||
"itemId": "item-861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"origin": {
|
||||
"method": "session/request_permission",
|
||||
"adapter": "acpx-runtime-sidecar",
|
||||
"provider": "acpx"
|
||||
},
|
||||
"reason": "get_task_context",
|
||||
"turnId": "turn_lab_71c34ac179ea42ac9e5a65138c15e2b7",
|
||||
"choices": [
|
||||
{
|
||||
"key": "accept",
|
||||
"label": "Allow once"
|
||||
},
|
||||
{
|
||||
"key": "accept_for_session",
|
||||
"label": "Allow for session"
|
||||
},
|
||||
{
|
||||
"key": "decline",
|
||||
"label": "Deny"
|
||||
},
|
||||
{
|
||||
"key": "cancel",
|
||||
"label": "Cancel"
|
||||
}
|
||||
],
|
||||
"threadId": "paperclip-bb9ec77ae62affebe9269afb211e8cff11b30059ee6155586f1ba46ef3758033"
|
||||
},
|
||||
"requestId": "acpx-input-5f82c73c88b9dec922b29e72",
|
||||
"requestKind": "permission_approval"
|
||||
}
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-03T23:15:25.534Z",
|
||||
"eventType": "runtime_request.created",
|
||||
"sourceSeq": 95,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "11a08fb7-ec9f-4496-9550-cc4decdeed09:861387cc-31c6-4761-bebb-24fde8539f4b:95",
|
||||
"sourceInstanceId": "11a08fb7-ec9f-4496-9550-cc4decdeed09",
|
||||
"normalizedSessionId": "456cb78e-0362-4f76-9133-5de358b4bc7f"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "11a08fb7-ec9f-4496-9550-cc4decdeed09",
|
||||
"sourceEventId": "11a08fb7-ec9f-4496-9550-cc4decdeed09:861387cc-31c6-4761-bebb-24fde8539f4b:95",
|
||||
"sourceSeq": 95,
|
||||
"sourcePayloadSha256": "eda0b599abce54f82f848325581fb142f53059e4e316d23362a0d1f46c8ffb46",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-03T23:15:25.547Z"
|
||||
},
|
||||
{
|
||||
"id": 120,
|
||||
"companyId": "6ce27423-9b7a-4423-9171-8d4d40e6c346",
|
||||
"runId": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"agentId": "1d6760b4-ebe7-4198-9f3d-5d4568b997c6",
|
||||
"seq": 120,
|
||||
"eventType": "provider.notice.recorded",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"itemId": "item-861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_71c34ac179ea42ac9e5a65138c15e2b7",
|
||||
"payload": {
|
||||
"scope": "turn",
|
||||
"schema": "paperclip.provider.notice.v1",
|
||||
"details": [
|
||||
{
|
||||
"name": "stage",
|
||||
"value": "tool"
|
||||
},
|
||||
{
|
||||
"name": "toolCallId",
|
||||
"value": "call_Qtd5aWLn5Mv1dNLnNEo7dkgF"
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"value": "pending"
|
||||
},
|
||||
{
|
||||
"name": "operation",
|
||||
"value": "read"
|
||||
}
|
||||
],
|
||||
"summary": "Copilot tool operation: pending.",
|
||||
"category": "copilot_tool_evidence_v1",
|
||||
"noticeId": "copilot-evidence-312ceb146a219ca749ed7bba-1",
|
||||
"severity": "info",
|
||||
"provenance": {
|
||||
"method": "session/update",
|
||||
"turnId": "turn_lab_71c34ac179ea42ac9e5a65138c15e2b7",
|
||||
"eventType": "tool",
|
||||
"sessionId": "64118500-7098-4903-830f-58adc1333b66"
|
||||
},
|
||||
"recoverable": true,
|
||||
"userActionable": false
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-03T23:15:25.534Z",
|
||||
"eventType": "provider.notice.recorded",
|
||||
"sourceSeq": 96,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "11a08fb7-ec9f-4496-9550-cc4decdeed09:861387cc-31c6-4761-bebb-24fde8539f4b:96",
|
||||
"sourceInstanceId": "11a08fb7-ec9f-4496-9550-cc4decdeed09",
|
||||
"normalizedSessionId": "456cb78e-0362-4f76-9133-5de358b4bc7f"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "11a08fb7-ec9f-4496-9550-cc4decdeed09",
|
||||
"sourceEventId": "11a08fb7-ec9f-4496-9550-cc4decdeed09:861387cc-31c6-4761-bebb-24fde8539f4b:96",
|
||||
"sourceSeq": 96,
|
||||
"sourcePayloadSha256": "b9878e600359fa61e28676ef597326323afe30bc50bdd7aece097c49598fa138",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-03T23:15:25.558Z"
|
||||
},
|
||||
{
|
||||
"id": 121,
|
||||
"companyId": "6ce27423-9b7a-4423-9171-8d4d40e6c346",
|
||||
"runId": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"agentId": "1d6760b4-ebe7-4198-9f3d-5d4568b997c6",
|
||||
"seq": 121,
|
||||
"eventType": "tool.execution.started",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"itemId": "item-861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_71c34ac179ea42ac9e5a65138c15e2b7",
|
||||
"payload": {
|
||||
"name": "paperclip-get_task_context",
|
||||
"schema": "paperclip.tool.execution.v1",
|
||||
"status": "running",
|
||||
"target": null,
|
||||
"exitCode": null,
|
||||
"progress": "paperclip-get_task_context (pending)",
|
||||
"readOnly": true,
|
||||
"namespace": null,
|
||||
"operation": "read",
|
||||
"transport": "builtin",
|
||||
"durationMs": null,
|
||||
"executionId": "call_Qtd5aWLn5Mv1dNLnNEo7dkgF",
|
||||
"outputBytes": 4,
|
||||
"inputUpdated": true,
|
||||
"outputDigest": "sha256:74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b",
|
||||
"outputTruncated": false
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-03T23:15:25.534Z",
|
||||
"eventType": "tool.execution.started",
|
||||
"sourceSeq": 97,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "11a08fb7-ec9f-4496-9550-cc4decdeed09:861387cc-31c6-4761-bebb-24fde8539f4b:97",
|
||||
"sourceInstanceId": "11a08fb7-ec9f-4496-9550-cc4decdeed09",
|
||||
"normalizedSessionId": "456cb78e-0362-4f76-9133-5de358b4bc7f"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "11a08fb7-ec9f-4496-9550-cc4decdeed09",
|
||||
"sourceEventId": "11a08fb7-ec9f-4496-9550-cc4decdeed09:861387cc-31c6-4761-bebb-24fde8539f4b:97",
|
||||
"sourceSeq": 97,
|
||||
"sourcePayloadSha256": "0c4214a7e47ecf29e5bf2296dad0a06e4d8b028c3dd32ea9756bf26287033e8c",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-03T23:15:25.562Z"
|
||||
},
|
||||
{
|
||||
"id": 122,
|
||||
"companyId": "6ce27423-9b7a-4423-9171-8d4d40e6c346",
|
||||
"runId": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"agentId": "1d6760b4-ebe7-4198-9f3d-5d4568b997c6",
|
||||
"seq": 122,
|
||||
"eventType": "provider.notice.recorded",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"itemId": "item-861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_71c34ac179ea42ac9e5a65138c15e2b7",
|
||||
"payload": {
|
||||
"scope": "turn",
|
||||
"schema": "paperclip.provider.notice.v1",
|
||||
"details": [
|
||||
{
|
||||
"name": "stage",
|
||||
"value": "permission_requested"
|
||||
},
|
||||
{
|
||||
"name": "toolCallId",
|
||||
"value": "call_Qtd5aWLn5Mv1dNLnNEo7dkgF"
|
||||
},
|
||||
{
|
||||
"name": "requestId",
|
||||
"value": "acpx-input-5f82c73c88b9dec922b29e72"
|
||||
},
|
||||
{
|
||||
"name": "declineOffered",
|
||||
"value": "true"
|
||||
}
|
||||
],
|
||||
"summary": "Copilot requested permission.",
|
||||
"category": "copilot_tool_evidence_v1",
|
||||
"noticeId": "copilot-evidence-312ceb146a219ca749ed7bba-2",
|
||||
"severity": "info",
|
||||
"provenance": {
|
||||
"method": "session/request_permission",
|
||||
"turnId": "turn_lab_71c34ac179ea42ac9e5a65138c15e2b7",
|
||||
"eventType": "permission_requested",
|
||||
"sessionId": "64118500-7098-4903-830f-58adc1333b66"
|
||||
},
|
||||
"recoverable": true,
|
||||
"userActionable": false
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-03T23:15:25.534Z",
|
||||
"eventType": "provider.notice.recorded",
|
||||
"sourceSeq": 98,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "11a08fb7-ec9f-4496-9550-cc4decdeed09:861387cc-31c6-4761-bebb-24fde8539f4b:98",
|
||||
"sourceInstanceId": "11a08fb7-ec9f-4496-9550-cc4decdeed09",
|
||||
"normalizedSessionId": "456cb78e-0362-4f76-9133-5de358b4bc7f"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "11a08fb7-ec9f-4496-9550-cc4decdeed09",
|
||||
"sourceEventId": "11a08fb7-ec9f-4496-9550-cc4decdeed09:861387cc-31c6-4761-bebb-24fde8539f4b:98",
|
||||
"sourceSeq": 98,
|
||||
"sourcePayloadSha256": "9da4e3c45baa601b100f472e3859ba1b0e00e80f685a4e3c5ba2653c3b9bfd78",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-03T23:15:25.566Z"
|
||||
},
|
||||
{
|
||||
"id": 123,
|
||||
"companyId": "6ce27423-9b7a-4423-9171-8d4d40e6c346",
|
||||
"runId": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"agentId": "1d6760b4-ebe7-4198-9f3d-5d4568b997c6",
|
||||
"seq": 123,
|
||||
"eventType": "runtime_request.resolved",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"itemId": "item-861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_71c34ac179ea42ac9e5a65138c15e2b7",
|
||||
"payload": {
|
||||
"action": "accept",
|
||||
"itemId": "item-861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"turnId": "turn_lab_71c34ac179ea42ac9e5a65138c15e2b7",
|
||||
"requestId": "acpx-input-5f82c73c88b9dec922b29e72",
|
||||
"requestKind": "permission_approval"
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-03T23:15:26.074Z",
|
||||
"eventType": "runtime_request.resolved",
|
||||
"sourceSeq": 99,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "11a08fb7-ec9f-4496-9550-cc4decdeed09:861387cc-31c6-4761-bebb-24fde8539f4b:99",
|
||||
"sourceInstanceId": "11a08fb7-ec9f-4496-9550-cc4decdeed09",
|
||||
"normalizedSessionId": "456cb78e-0362-4f76-9133-5de358b4bc7f"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "11a08fb7-ec9f-4496-9550-cc4decdeed09",
|
||||
"sourceEventId": "11a08fb7-ec9f-4496-9550-cc4decdeed09:861387cc-31c6-4761-bebb-24fde8539f4b:99",
|
||||
"sourceSeq": 99,
|
||||
"sourcePayloadSha256": "05f1aa32fceb8ae5a3ce2c3cfbc7315d2c658d302bb866c81dc0cf8fcdcea066",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-03T23:15:26.075Z"
|
||||
},
|
||||
{
|
||||
"id": 124,
|
||||
"companyId": "6ce27423-9b7a-4423-9171-8d4d40e6c346",
|
||||
"runId": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"agentId": "1d6760b4-ebe7-4198-9f3d-5d4568b997c6",
|
||||
"seq": 124,
|
||||
"eventType": "provider.notice.recorded",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"itemId": "item-861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_71c34ac179ea42ac9e5a65138c15e2b7",
|
||||
"payload": {
|
||||
"scope": "turn",
|
||||
"schema": "paperclip.provider.notice.v1",
|
||||
"details": [
|
||||
{
|
||||
"name": "stage",
|
||||
"value": "permission_delivered"
|
||||
},
|
||||
{
|
||||
"name": "toolCallId",
|
||||
"value": "call_Qtd5aWLn5Mv1dNLnNEo7dkgF"
|
||||
},
|
||||
{
|
||||
"name": "requestId",
|
||||
"value": "acpx-input-5f82c73c88b9dec922b29e72"
|
||||
},
|
||||
{
|
||||
"name": "declineOffered",
|
||||
"value": "true"
|
||||
},
|
||||
{
|
||||
"name": "outcome",
|
||||
"value": "allow_once"
|
||||
}
|
||||
],
|
||||
"summary": "Copilot received your permission decision.",
|
||||
"category": "copilot_tool_evidence_v1",
|
||||
"noticeId": "copilot-evidence-312ceb146a219ca749ed7bba-3",
|
||||
"severity": "info",
|
||||
"provenance": {
|
||||
"method": "session/request_permission",
|
||||
"turnId": "turn_lab_71c34ac179ea42ac9e5a65138c15e2b7",
|
||||
"eventType": "permission_delivered",
|
||||
"sessionId": "64118500-7098-4903-830f-58adc1333b66"
|
||||
},
|
||||
"recoverable": true,
|
||||
"userActionable": false
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-03T23:15:26.664Z",
|
||||
"eventType": "provider.notice.recorded",
|
||||
"sourceSeq": 100,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "11a08fb7-ec9f-4496-9550-cc4decdeed09:861387cc-31c6-4761-bebb-24fde8539f4b:100",
|
||||
"sourceInstanceId": "11a08fb7-ec9f-4496-9550-cc4decdeed09",
|
||||
"normalizedSessionId": "456cb78e-0362-4f76-9133-5de358b4bc7f"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "11a08fb7-ec9f-4496-9550-cc4decdeed09",
|
||||
"sourceEventId": "11a08fb7-ec9f-4496-9550-cc4decdeed09:861387cc-31c6-4761-bebb-24fde8539f4b:100",
|
||||
"sourceSeq": 100,
|
||||
"sourcePayloadSha256": "fe0d38bdaf1e6e14678679ae7af4192f54c43ea1b505ac0d4e88d6a274d28bd5",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-03T23:15:26.675Z"
|
||||
},
|
||||
{
|
||||
"id": 128,
|
||||
"companyId": "6ce27423-9b7a-4423-9171-8d4d40e6c346",
|
||||
"runId": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"agentId": "1d6760b4-ebe7-4198-9f3d-5d4568b997c6",
|
||||
"seq": 128,
|
||||
"eventType": "provider.notice.recorded",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"itemId": "item-861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_71c34ac179ea42ac9e5a65138c15e2b7",
|
||||
"payload": {
|
||||
"scope": "turn",
|
||||
"schema": "paperclip.provider.notice.v1",
|
||||
"details": [
|
||||
{
|
||||
"name": "stage",
|
||||
"value": "semantic_result"
|
||||
},
|
||||
{
|
||||
"name": "schema",
|
||||
"value": "paperclip.semantic_tool_receipt.v2"
|
||||
},
|
||||
{
|
||||
"name": "operationId",
|
||||
"value": "get_task_context"
|
||||
},
|
||||
{
|
||||
"name": "callIdentitySha256",
|
||||
"value": "4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce"
|
||||
},
|
||||
{
|
||||
"name": "inputSha256",
|
||||
"value": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
|
||||
},
|
||||
{
|
||||
"name": "resultSha256",
|
||||
"value": "8b849d7fd1959bdad785ac51b7e6cfd6ba9fa42e986c3dfdb17ad32b0ec212eb"
|
||||
},
|
||||
{
|
||||
"name": "outcome",
|
||||
"value": "returned"
|
||||
},
|
||||
{
|
||||
"name": "normalizedInputSha256",
|
||||
"value": "null"
|
||||
}
|
||||
],
|
||||
"summary": "Paperclip returned a semantic tool result.",
|
||||
"category": "paperclip_semantic_tool_receipt_v2",
|
||||
"noticeId": "copilot-evidence-312ceb146a219ca749ed7bba-4",
|
||||
"severity": "info",
|
||||
"provenance": {
|
||||
"method": "paperclip/semantic_tool_result",
|
||||
"turnId": "turn_lab_71c34ac179ea42ac9e5a65138c15e2b7",
|
||||
"eventType": "semantic_result",
|
||||
"sessionId": "64118500-7098-4903-830f-58adc1333b66"
|
||||
},
|
||||
"recoverable": true,
|
||||
"userActionable": false
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-03T23:15:27.096Z",
|
||||
"eventType": "provider.notice.recorded",
|
||||
"sourceSeq": 103,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "11a08fb7-ec9f-4496-9550-cc4decdeed09:861387cc-31c6-4761-bebb-24fde8539f4b:103",
|
||||
"sourceInstanceId": "11a08fb7-ec9f-4496-9550-cc4decdeed09",
|
||||
"normalizedSessionId": "456cb78e-0362-4f76-9133-5de358b4bc7f"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "11a08fb7-ec9f-4496-9550-cc4decdeed09",
|
||||
"sourceEventId": "11a08fb7-ec9f-4496-9550-cc4decdeed09:861387cc-31c6-4761-bebb-24fde8539f4b:103",
|
||||
"sourceSeq": 103,
|
||||
"sourcePayloadSha256": "cbedf804f3e26eeae5afbc431bff2db3acdbecf7fd521fc988a682a465f642b1",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-03T23:15:27.139Z"
|
||||
},
|
||||
{
|
||||
"id": 129,
|
||||
"companyId": "6ce27423-9b7a-4423-9171-8d4d40e6c346",
|
||||
"runId": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"agentId": "1d6760b4-ebe7-4198-9f3d-5d4568b997c6",
|
||||
"seq": 129,
|
||||
"eventType": "provider.notice.recorded",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"itemId": "item-861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_71c34ac179ea42ac9e5a65138c15e2b7",
|
||||
"payload": {
|
||||
"scope": "turn",
|
||||
"schema": "paperclip.provider.notice.v1",
|
||||
"details": [
|
||||
{
|
||||
"name": "stage",
|
||||
"value": "tool"
|
||||
},
|
||||
{
|
||||
"name": "toolCallId",
|
||||
"value": "call_Qtd5aWLn5Mv1dNLnNEo7dkgF"
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"value": "completed"
|
||||
},
|
||||
{
|
||||
"name": "operation",
|
||||
"value": "read"
|
||||
},
|
||||
{
|
||||
"name": "semanticOperationId",
|
||||
"value": "get_task_context"
|
||||
},
|
||||
{
|
||||
"name": "semanticCallIdentitySha256",
|
||||
"value": "4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce"
|
||||
},
|
||||
{
|
||||
"name": "semanticInputSha256",
|
||||
"value": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
|
||||
},
|
||||
{
|
||||
"name": "semanticNormalizedInputSha256",
|
||||
"value": "null"
|
||||
},
|
||||
{
|
||||
"name": "semanticResultSha256",
|
||||
"value": "8b849d7fd1959bdad785ac51b7e6cfd6ba9fa42e986c3dfdb17ad32b0ec212eb"
|
||||
},
|
||||
{
|
||||
"name": "semanticOutcome",
|
||||
"value": "returned"
|
||||
}
|
||||
],
|
||||
"summary": "Copilot tool operation: completed.",
|
||||
"category": "copilot_tool_evidence_v1",
|
||||
"noticeId": "copilot-evidence-312ceb146a219ca749ed7bba-5",
|
||||
"severity": "info",
|
||||
"provenance": {
|
||||
"method": "session/update",
|
||||
"turnId": "turn_lab_71c34ac179ea42ac9e5a65138c15e2b7",
|
||||
"eventType": "tool",
|
||||
"sessionId": "64118500-7098-4903-830f-58adc1333b66"
|
||||
},
|
||||
"recoverable": true,
|
||||
"userActionable": false
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-03T23:15:27.139Z",
|
||||
"eventType": "provider.notice.recorded",
|
||||
"sourceSeq": 104,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "11a08fb7-ec9f-4496-9550-cc4decdeed09:861387cc-31c6-4761-bebb-24fde8539f4b:104",
|
||||
"sourceInstanceId": "11a08fb7-ec9f-4496-9550-cc4decdeed09",
|
||||
"normalizedSessionId": "456cb78e-0362-4f76-9133-5de358b4bc7f"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "11a08fb7-ec9f-4496-9550-cc4decdeed09",
|
||||
"sourceEventId": "11a08fb7-ec9f-4496-9550-cc4decdeed09:861387cc-31c6-4761-bebb-24fde8539f4b:104",
|
||||
"sourceSeq": 104,
|
||||
"sourcePayloadSha256": "809ae9305e5de108d9e2ff4131e62ea6bb8dc96402ddfbe59f49463ad9eec80a",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-03T23:15:27.151Z"
|
||||
},
|
||||
{
|
||||
"id": 130,
|
||||
"companyId": "6ce27423-9b7a-4423-9171-8d4d40e6c346",
|
||||
"runId": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"agentId": "1d6760b4-ebe7-4198-9f3d-5d4568b997c6",
|
||||
"seq": 130,
|
||||
"eventType": "tool.execution.completed",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"itemId": "item-861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_71c34ac179ea42ac9e5a65138c15e2b7",
|
||||
"payload": {
|
||||
"name": "paperclip-get_task_context",
|
||||
"schema": "paperclip.tool.execution.v1",
|
||||
"status": "completed",
|
||||
"target": null,
|
||||
"exitCode": null,
|
||||
"progress": null,
|
||||
"readOnly": true,
|
||||
"namespace": null,
|
||||
"operation": "read",
|
||||
"transport": "builtin",
|
||||
"durationMs": null,
|
||||
"executionId": "call_Qtd5aWLn5Mv1dNLnNEo7dkgF",
|
||||
"outputBytes": 4110,
|
||||
"inputUpdated": false,
|
||||
"outputDigest": "sha256:aea3a374b88ed64d03eb099029c1c5c3ae244ba1e38daa7b8a5aa15d53e048a0",
|
||||
"outputTruncated": false
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-03T23:15:27.139Z",
|
||||
"eventType": "tool.execution.completed",
|
||||
"sourceSeq": 105,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "11a08fb7-ec9f-4496-9550-cc4decdeed09:861387cc-31c6-4761-bebb-24fde8539f4b:105",
|
||||
"sourceInstanceId": "11a08fb7-ec9f-4496-9550-cc4decdeed09",
|
||||
"normalizedSessionId": "456cb78e-0362-4f76-9133-5de358b4bc7f"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "11a08fb7-ec9f-4496-9550-cc4decdeed09",
|
||||
"sourceEventId": "11a08fb7-ec9f-4496-9550-cc4decdeed09:861387cc-31c6-4761-bebb-24fde8539f4b:105",
|
||||
"sourceSeq": 105,
|
||||
"sourcePayloadSha256": "4f7b8bcdefe69f0981507f1a3c11ec013337e8b0c0e1b23207a5d6ece3d8f0dd",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-03T23:15:27.155Z"
|
||||
},
|
||||
{
|
||||
"id": 134,
|
||||
"companyId": "6ce27423-9b7a-4423-9171-8d4d40e6c346",
|
||||
"runId": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"agentId": "1d6760b4-ebe7-4198-9f3d-5d4568b997c6",
|
||||
"seq": 134,
|
||||
"eventType": "runtime_request.created",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"itemId": "item-861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_71c34ac179ea42ac9e5a65138c15e2b7",
|
||||
"payload": {
|
||||
"request": {
|
||||
"type": "permission",
|
||||
"itemId": "item-861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"method": "session/request_permission",
|
||||
"origin": {
|
||||
"method": "session/request_permission",
|
||||
"adapter": "acpx-runtime-sidecar",
|
||||
"provider": "acpx"
|
||||
},
|
||||
"prompt": "Change file: pc-denied-dUYoVS/active-stop-80fcac4754d6-1.txt",
|
||||
"schema": "paperclip.runtime_request.v2",
|
||||
"status": "pending",
|
||||
"turnId": "turn_lab_71c34ac179ea42ac9e5a65138c15e2b7",
|
||||
"choices": [
|
||||
{
|
||||
"key": "accept",
|
||||
"label": "Allow once"
|
||||
},
|
||||
{
|
||||
"key": "accept_for_session",
|
||||
"label": "Allow for session"
|
||||
},
|
||||
{
|
||||
"key": "decline",
|
||||
"label": "Deny"
|
||||
},
|
||||
{
|
||||
"key": "cancel",
|
||||
"label": "Cancel"
|
||||
}
|
||||
],
|
||||
"details": {
|
||||
"itemId": "item-861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"origin": {
|
||||
"method": "session/request_permission",
|
||||
"adapter": "acpx-runtime-sidecar",
|
||||
"provider": "acpx"
|
||||
},
|
||||
"reason": "Change file: pc-denied-dUYoVS/active-stop-80fcac4754d6-1.txt",
|
||||
"turnId": "turn_lab_71c34ac179ea42ac9e5a65138c15e2b7",
|
||||
"choices": [
|
||||
{
|
||||
"key": "accept",
|
||||
"label": "Allow once"
|
||||
},
|
||||
{
|
||||
"key": "accept_for_session",
|
||||
"label": "Allow for session"
|
||||
},
|
||||
{
|
||||
"key": "decline",
|
||||
"label": "Deny"
|
||||
},
|
||||
{
|
||||
"key": "cancel",
|
||||
"label": "Cancel"
|
||||
}
|
||||
],
|
||||
"threadId": "paperclip-bb9ec77ae62affebe9269afb211e8cff11b30059ee6155586f1ba46ef3758033"
|
||||
},
|
||||
"requestId": "acpx-input-ccaa35c8d2130be28a27e1d3",
|
||||
"requestKind": "permission_approval"
|
||||
}
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-03T23:15:28.325Z",
|
||||
"eventType": "runtime_request.created",
|
||||
"sourceSeq": 109,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "11a08fb7-ec9f-4496-9550-cc4decdeed09:861387cc-31c6-4761-bebb-24fde8539f4b:109",
|
||||
"sourceInstanceId": "11a08fb7-ec9f-4496-9550-cc4decdeed09",
|
||||
"normalizedSessionId": "456cb78e-0362-4f76-9133-5de358b4bc7f"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "11a08fb7-ec9f-4496-9550-cc4decdeed09",
|
||||
"sourceEventId": "11a08fb7-ec9f-4496-9550-cc4decdeed09:861387cc-31c6-4761-bebb-24fde8539f4b:109",
|
||||
"sourceSeq": 109,
|
||||
"sourcePayloadSha256": "11628254fc5e512ff7734b3ef9416fd9f223ec78a756e86a2a4673fbc02ccb75",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-03T23:15:28.333Z"
|
||||
},
|
||||
{
|
||||
"id": 135,
|
||||
"companyId": "6ce27423-9b7a-4423-9171-8d4d40e6c346",
|
||||
"runId": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"agentId": "1d6760b4-ebe7-4198-9f3d-5d4568b997c6",
|
||||
"seq": 135,
|
||||
"eventType": "provider.notice.recorded",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"itemId": "item-861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_71c34ac179ea42ac9e5a65138c15e2b7",
|
||||
"payload": {
|
||||
"scope": "turn",
|
||||
"schema": "paperclip.provider.notice.v1",
|
||||
"details": [
|
||||
{
|
||||
"name": "stage",
|
||||
"value": "tool"
|
||||
},
|
||||
{
|
||||
"name": "toolCallId",
|
||||
"value": "custom_call_jyQm34Kq61JjSxkZUXvPSNrh"
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"value": "pending"
|
||||
},
|
||||
{
|
||||
"name": "operation",
|
||||
"value": "edit"
|
||||
}
|
||||
],
|
||||
"summary": "Copilot file operation: pending.",
|
||||
"category": "copilot_tool_evidence_v1",
|
||||
"noticeId": "copilot-evidence-312ceb146a219ca749ed7bba-6",
|
||||
"severity": "info",
|
||||
"provenance": {
|
||||
"method": "session/update",
|
||||
"turnId": "turn_lab_71c34ac179ea42ac9e5a65138c15e2b7",
|
||||
"eventType": "tool",
|
||||
"sessionId": "64118500-7098-4903-830f-58adc1333b66"
|
||||
},
|
||||
"recoverable": true,
|
||||
"userActionable": false
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-03T23:15:28.325Z",
|
||||
"eventType": "provider.notice.recorded",
|
||||
"sourceSeq": 110,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "11a08fb7-ec9f-4496-9550-cc4decdeed09:861387cc-31c6-4761-bebb-24fde8539f4b:110",
|
||||
"sourceInstanceId": "11a08fb7-ec9f-4496-9550-cc4decdeed09",
|
||||
"normalizedSessionId": "456cb78e-0362-4f76-9133-5de358b4bc7f"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "11a08fb7-ec9f-4496-9550-cc4decdeed09",
|
||||
"sourceEventId": "11a08fb7-ec9f-4496-9550-cc4decdeed09:861387cc-31c6-4761-bebb-24fde8539f4b:110",
|
||||
"sourceSeq": 110,
|
||||
"sourcePayloadSha256": "6e50e44f381af9824aa37cde550765cc90f851260c5b9956de4e1d242a31e3a8",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-03T23:15:28.337Z"
|
||||
},
|
||||
{
|
||||
"id": 136,
|
||||
"companyId": "6ce27423-9b7a-4423-9171-8d4d40e6c346",
|
||||
"runId": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"agentId": "1d6760b4-ebe7-4198-9f3d-5d4568b997c6",
|
||||
"seq": 136,
|
||||
"eventType": "tool.execution.started",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"itemId": "item-861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_71c34ac179ea42ac9e5a65138c15e2b7",
|
||||
"payload": {
|
||||
"name": "apply_patch",
|
||||
"schema": "paperclip.tool.execution.v1",
|
||||
"status": "running",
|
||||
"target": null,
|
||||
"exitCode": null,
|
||||
"progress": "apply_patch (pending): *** Begin Patch\n*** Add File: pc-denied-dUYoVS/active-stop-80fcac4754d6-1.txt\n+MUST_NOT_EXIST\n*** End Patch\n",
|
||||
"readOnly": false,
|
||||
"namespace": null,
|
||||
"operation": "edit",
|
||||
"transport": "builtin",
|
||||
"durationMs": null,
|
||||
"executionId": "custom_call_jyQm34Kq61JjSxkZUXvPSNrh",
|
||||
"outputBytes": 4,
|
||||
"inputUpdated": true,
|
||||
"outputDigest": "sha256:74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b",
|
||||
"outputTruncated": false
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-03T23:15:28.325Z",
|
||||
"eventType": "tool.execution.started",
|
||||
"sourceSeq": 111,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "11a08fb7-ec9f-4496-9550-cc4decdeed09:861387cc-31c6-4761-bebb-24fde8539f4b:111",
|
||||
"sourceInstanceId": "11a08fb7-ec9f-4496-9550-cc4decdeed09",
|
||||
"normalizedSessionId": "456cb78e-0362-4f76-9133-5de358b4bc7f"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "11a08fb7-ec9f-4496-9550-cc4decdeed09",
|
||||
"sourceEventId": "11a08fb7-ec9f-4496-9550-cc4decdeed09:861387cc-31c6-4761-bebb-24fde8539f4b:111",
|
||||
"sourceSeq": 111,
|
||||
"sourcePayloadSha256": "d99db3d323a1814c586fb7eebc0568648bcd4fc361cbfc785068c327d45df398",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-03T23:15:28.340Z"
|
||||
},
|
||||
{
|
||||
"id": 137,
|
||||
"companyId": "6ce27423-9b7a-4423-9171-8d4d40e6c346",
|
||||
"runId": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"agentId": "1d6760b4-ebe7-4198-9f3d-5d4568b997c6",
|
||||
"seq": 137,
|
||||
"eventType": "provider.notice.recorded",
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"color": null,
|
||||
"message": null,
|
||||
"payload": {
|
||||
"prpEvent": {
|
||||
"runId": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"itemId": "item-861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"turnId": "turn_lab_71c34ac179ea42ac9e5a65138c15e2b7",
|
||||
"payload": {
|
||||
"scope": "turn",
|
||||
"schema": "paperclip.provider.notice.v1",
|
||||
"details": [
|
||||
{
|
||||
"name": "stage",
|
||||
"value": "permission_requested"
|
||||
},
|
||||
{
|
||||
"name": "toolCallId",
|
||||
"value": "custom_call_jyQm34Kq61JjSxkZUXvPSNrh"
|
||||
},
|
||||
{
|
||||
"name": "requestId",
|
||||
"value": "acpx-input-ccaa35c8d2130be28a27e1d3"
|
||||
},
|
||||
{
|
||||
"name": "target",
|
||||
"value": "pc-denied-dUYoVS/active-stop-80fcac4754d6-1.txt"
|
||||
},
|
||||
{
|
||||
"name": "operation",
|
||||
"value": "edit"
|
||||
},
|
||||
{
|
||||
"name": "declineOffered",
|
||||
"value": "true"
|
||||
}
|
||||
],
|
||||
"summary": "Copilot requested permission.",
|
||||
"category": "copilot_tool_evidence_v1",
|
||||
"noticeId": "copilot-evidence-312ceb146a219ca749ed7bba-7",
|
||||
"severity": "info",
|
||||
"provenance": {
|
||||
"method": "session/request_permission",
|
||||
"turnId": "turn_lab_71c34ac179ea42ac9e5a65138c15e2b7",
|
||||
"eventType": "permission_requested",
|
||||
"sessionId": "64118500-7098-4903-830f-58adc1333b66"
|
||||
},
|
||||
"recoverable": true,
|
||||
"userActionable": false
|
||||
},
|
||||
"priority": 1,
|
||||
"emittedAt": "2026-10-03T23:15:28.325Z",
|
||||
"eventType": "provider.notice.recorded",
|
||||
"sourceSeq": 112,
|
||||
"sourceKind": "runner",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "11a08fb7-ec9f-4496-9550-cc4decdeed09:861387cc-31c6-4761-bebb-24fde8539f4b:112",
|
||||
"sourceInstanceId": "11a08fb7-ec9f-4496-9550-cc4decdeed09",
|
||||
"normalizedSessionId": "456cb78e-0362-4f76-9133-5de358b4bc7f"
|
||||
}
|
||||
},
|
||||
"sourceInstanceId": "11a08fb7-ec9f-4496-9550-cc4decdeed09",
|
||||
"sourceEventId": "11a08fb7-ec9f-4496-9550-cc4decdeed09:861387cc-31c6-4761-bebb-24fde8539f4b:112",
|
||||
"sourceSeq": 112,
|
||||
"sourcePayloadSha256": "068b266a639517b20a5babe71b6e61f8dc6777e2f0b234860fa5b22a326c1d8f",
|
||||
"protocolSchemaVersion": 1,
|
||||
"createdAt": "2026-10-03T23:15:28.343Z"
|
||||
}
|
||||
],
|
||||
"run": {
|
||||
"id": "861387cc-31c6-4761-bebb-24fde8539f4b",
|
||||
"companyId": "6ce27423-9b7a-4423-9171-8d4d40e6c346",
|
||||
"nativeIssueId": "af0fd9f5-9bdd-42f4-a214-328919e315f7",
|
||||
"runtimeMode": "native",
|
||||
"status": "running",
|
||||
"resultJson": {}
|
||||
},
|
||||
"issue": {
|
||||
"id": "af0fd9f5-9bdd-42f4-a214-328919e315f7",
|
||||
"companyId": "6ce27423-9b7a-4423-9171-8d4d40e6c346",
|
||||
"status": "in_progress"
|
||||
}
|
||||
}
|
||||
@@ -115,8 +115,7 @@ export function buildRunnerE2EProcessEnvironment(
|
||||
for (const execution of executions) {
|
||||
const agent = execution.profile.qualificationCandidate;
|
||||
if (!agent || agent === "cursor") continue;
|
||||
const admittedSuite = execution.suite.id === "extended-harnesses"
|
||||
|| execution.suite.id === "rich-acp-warm-continuity";
|
||||
const admittedSuite = execution.suite.id === "extended-harnesses" || (agent === "copilot" && ["rich-acp-warm-continuity", "copilot-protection", "native-active-stop", "copilot-provider-death"].includes(execution.suite.id));
|
||||
if (!admittedSuite || !execution.suite.manualOnly) {
|
||||
throw new Error("Candidate qualification requires an explicit provider qualification suite");
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ interface AgentRecord {
|
||||
interface ManagedAccountFixture {
|
||||
connectionId: string;
|
||||
binding: {
|
||||
provider: "openai" | "anthropic" | "openrouter";
|
||||
provider: "openai" | "anthropic" | "github" | "openrouter";
|
||||
method: "api_key";
|
||||
mode: "responsible_user";
|
||||
};
|
||||
@@ -242,14 +242,15 @@ export async function setupLiveFixtures(input: {
|
||||
});
|
||||
|
||||
const managedHiring = isManagedHiringCase(execution.suite.id, execution.task.id);
|
||||
if (managedHiring) {
|
||||
const savedCopilot = execution.profile.qualificationCandidate === "copilot";
|
||||
if (managedHiring || savedCopilot) {
|
||||
registry.register<ManagedAccountFixture>({
|
||||
id: "ai-connection",
|
||||
dependencies: ["company"],
|
||||
dependencies: ["company", ...(savedCopilot ? ["environment"] : [])],
|
||||
async setup(resolved) {
|
||||
const company = value<CompanyRecord>(resolved, "company");
|
||||
const key = execution.profile.credential;
|
||||
const provider = key === "ANTHROPIC_API_KEY" ? "anthropic"
|
||||
const key = savedCopilot ? "COPILOT_GITHUB_TOKEN" : execution.profile.credential;
|
||||
const provider = savedCopilot ? "github" : key === "ANTHROPIC_API_KEY" ? "anthropic"
|
||||
: key === "OPENROUTER_API_KEY" ? "openrouter"
|
||||
: key === "OPENAI_API_KEY" ? "openai" : null;
|
||||
if (!provider) throw new Error(`Unsupported managed hiring credential ${key}`);
|
||||
@@ -262,6 +263,7 @@ export async function setupLiveFixtures(input: {
|
||||
method: "api_key",
|
||||
name: `Runner E2E account ${input.executionNonce}`,
|
||||
ownership: "personal",
|
||||
...(savedCopilot ? { environmentId: value<EnvironmentRecord>(resolved, "environment").id } : {}),
|
||||
apiKey,
|
||||
agentIds: [],
|
||||
allAgents: false,
|
||||
@@ -282,7 +284,7 @@ export async function setupLiveFixtures(input: {
|
||||
"secrets",
|
||||
"environment",
|
||||
...(grokSubscription ? ["subscription-login"] : []),
|
||||
...(managedHiring ? ["ai-connection"] : []),
|
||||
...(managedHiring || savedCopilot ? ["ai-connection"] : []),
|
||||
],
|
||||
async setup(resolved) {
|
||||
const company = value<CompanyRecord>(resolved, "company");
|
||||
@@ -299,7 +301,7 @@ export async function setupLiveFixtures(input: {
|
||||
|| (execution.suite.id === "everyday-workflows" && ["hire-reuse", "delegate-feedback"].includes(execution.task.id))) {
|
||||
agent.budgetMonthlyCents = 1_000;
|
||||
}
|
||||
if (managedHiring) {
|
||||
if (managedHiring || savedCopilot) {
|
||||
const account = value<ManagedAccountFixture>(resolved, "ai-connection");
|
||||
const config = agent.adapterConfig as Record<string, unknown>;
|
||||
delete config.env;
|
||||
|
||||
@@ -369,11 +369,13 @@ describe("active Stop flow wiring", () => {
|
||||
describe("explicit active Stop discovery", () => {
|
||||
it("adds exactly two versioned cells without enabling them in --all", () => {
|
||||
const suite = runnerSuites.find(s => s.id === "native-active-stop")!;
|
||||
const cells = runnerMatrix.filter(e => e.suite === suite);
|
||||
const allCells = runnerMatrix.filter(e => e.suite === suite);
|
||||
expect(allCells).toHaveLength(4);
|
||||
const cells = allCells.filter(e => e.profile.qualificationCandidate === "cursor");
|
||||
expect(cells).toHaveLength(2); expect(suite.manualOnly).toBe(true);
|
||||
expect(cells.map(e => `${e.profile.qualificationCandidate}/${e.environment.id}`).sort()).toEqual(["cursor/daytona", "cursor/local"]);
|
||||
expect(cells.every(e => e.task.expectedRunCount === 1 && e.task.flow === "native_active_stop" && e.task.expectedTerminalState?.run === "cancelled")).toBe(true);
|
||||
expect(suite.definitionMetadata).toMatchObject({ version: 4, evidence: "paperclip.e2e.native-active-stop-settlement.v2", normalCompletionAccepted: false, providerDeath: "not-covered" });
|
||||
expect(suite.definitionMetadata).toMatchObject({ version: 19, evidence: "paperclip.e2e.native-active-stop-settlement.v2", normalCompletionAccepted: false, providerDeath: "not-covered" });
|
||||
expect(suiteDefinitionHash(suite)).not.toBe(suiteDefinitionHash({ ...suite, definitionMetadata: { ...suite.definitionMetadata, version: 3 } }));
|
||||
expect(suiteDefinitionHash(suite)).not.toBe(suiteDefinitionHash({ ...suite, definitionMetadata: { ...suite.definitionMetadata, normalCompletionAccepted: true } }));
|
||||
expect(selectRunnerExecutions(parseRunnerSelectors(["--all"])).some(e => e.suite.id === suite.id)).toBe(false);
|
||||
|
||||
@@ -81,10 +81,13 @@ it("accepts omitted terminal path/kind only with the retained single-path origin
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["cursor"] as const)("correlates actual %s passive notices through the public reader and canonical omitted-field updates", async provider => {
|
||||
it.each(["cursor", "copilot"] as const)("correlates actual %s passive notices through the public reader and canonical omitted-field updates", async provider => {
|
||||
const { createCursorToolEvidence } = await import("../../packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.js");
|
||||
const { createCopilotToolEvidence } = await import("../../packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.js");
|
||||
const { readCursorToolEvidence } = await import("./cursor-native-evidence.js");
|
||||
const create = createCursorToolEvidence;
|
||||
const { readCopilotToolEvidence } = await import("./copilot-evidence.js");
|
||||
const create = provider === "cursor" ? createCursorToolEvidence : createCopilotToolEvidence;
|
||||
const readEvidence = provider === "cursor" ? readCursorToolEvidence : readCopilotToolEvidence;
|
||||
for (const variant of ["exact", "wrong-path", "multi-path", "ambiguous-update"]) {
|
||||
const rows: any[] = [];
|
||||
const projector = create({ sessionId: "session", turnId: "turn", workingDirectory: "/workspace", active: () => true,
|
||||
@@ -96,7 +99,7 @@ it.each(["cursor"] as const)("correlates actual %s passive notices through the p
|
||||
projector.tool({ type: "tool_call", tag: "tool_call", toolCallId: "bootstrap", kind: "read", status: "pending", rawInput: variant === "multi-path" ? { paths: [path, "private.txt"] } : { path }, locations: [{ path }] });
|
||||
projector.tool({ type: "tool_call", tag: "tool_call_update", toolCallId: "bootstrap", status: "failed", ...(variant === "ambiguous-update" ? { rawInput: { path, paths: [path, "private.txt"] } } : {}) });
|
||||
const evaluate = () => {
|
||||
const readNotices = readCursorToolEvidence(rows, "run");
|
||||
const readNotices = readEvidence(rows, "run");
|
||||
const started = receipt("started", "running", 2), end = receipt("completed", "failed", 4);
|
||||
Object.assign(end.payload.prpEvent.payload, { target: null, operation: "unknown" });
|
||||
return withoutProvenBootstrapReads([...readNotices, origin], origin, { actionFile, events: [...rows, started, end] });
|
||||
|
||||
@@ -3,7 +3,7 @@ import { defineConfig } from "@playwright/test";
|
||||
/** Browser-only harness regressions: no Paperclip instance or provider credentials. */
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: ["screenshot-readiness.spec.ts", "service-worker-reload.spec.ts", "lost-send.spec.ts", "chat-restart.spec.ts", "settings-toggle.spec.ts", "browser-bootstrap-diagnostics.spec.ts", "service-worker-module-loading.spec.ts", "browser-bootstrap-recovery.spec.ts", "blocker-input.spec.ts", "native-blocker-visible.spec.ts"],
|
||||
testMatch: ["screenshot-readiness.spec.ts", "service-worker-reload.spec.ts", "lost-send.spec.ts", "chat-restart.spec.ts", "settings-toggle.spec.ts", "browser-bootstrap-diagnostics.spec.ts", "service-worker-module-loading.spec.ts", "browser-bootstrap-recovery.spec.ts", "blocker-input.spec.ts", "native-blocker-visible.spec.ts", "copilot-permission-card.spec.ts"],
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
timeout: 10_000,
|
||||
|
||||
@@ -18,7 +18,7 @@ export function assertRemoteNativeEvidencePrerequisites(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
): void {
|
||||
if (!executions.some(execution => execution.environment.id === "daytona"
|
||||
&& ["cursor_native", "native_active_stop", "native_provider_loss"].includes(execution.task.flow))) return;
|
||||
&& ["copilot_protection", "native_active_stop", "cursor_native", "native_provider_loss"].includes(execution.task.flow))) return;
|
||||
const names = ["PAPERCLIP_E2E_DAYTONA_NODE_SHA256", "PAPERCLIP_E2E_DAYTONA_RUNNERD_SHA256"];
|
||||
const invalid = names.filter(name => !/^sha256:[a-f0-9]{64}$/u.test(environment[name] ?? ""));
|
||||
if (invalid.length) throw new Error(`Native Daytona evidence requires exact image executable digests: ${invalid.join(", ")}`);
|
||||
|
||||
@@ -27,6 +27,30 @@ it.skipIf(process.platform === "win32")("revalidates PID/start before signaling
|
||||
} finally { f.owner.stopObserving(); }
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")("keeps surviving peer identities when a descendant traversal ends at a short-lived child", async () => {
|
||||
const f = fixture([row(100, process.pid, 100), row(101, 100, 100), row(200, 100, 200), row(201, 200, 200)]);
|
||||
try {
|
||||
await f.owner.observe();
|
||||
await f.owner.observe();
|
||||
f.replace([row(100, process.pid, 100), row(200, 100, 200)]);
|
||||
await f.owner.signal("SIGKILL");
|
||||
expect(f.signals).toEqual([[200, "SIGKILL"], [100, "SIGKILL"]]);
|
||||
} finally { f.owner.stopObserving(); }
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")("keeps surviving peers after the launcher exits without admitting an unobserved group", async () => {
|
||||
const f = fixture([row(100, process.pid, 100), row(200, 100, 200), row(201, 200, 200)]);
|
||||
try {
|
||||
await f.owner.observe();
|
||||
f.root.exitCode = 0;
|
||||
f.replace([row(200, 1, 200), row(201, 200, 200)]);
|
||||
await f.owner.observe();
|
||||
f.replace([row(200, 1, 200), row(999, 1, 999)]);
|
||||
await f.owner.signal("SIGTERM");
|
||||
expect(f.signals).toEqual([[200, "SIGTERM"]]);
|
||||
} finally { f.owner.stopObserving(); }
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")("retains observed descendants when the root exits and excludes the caller group", async () => {
|
||||
const f = fixture([row(100, process.pid, 100), row(200, 100, 200), row(300, 100, 10), row(999, process.pid, 999)]);
|
||||
try {
|
||||
|
||||
@@ -41,7 +41,15 @@ export function createProcessTreeOwner(root: ChildProcess, options: {
|
||||
for (const pid of anchors) {
|
||||
for (const group of observeDescendantProcessTree(next, pid).groups) {
|
||||
if (group.processGroupId !== callerGroup) {
|
||||
byGroup.set(group.processGroupId, group);
|
||||
// A descendant traversal can cover only part of an already
|
||||
// validated group. Keep every observed peer as an identity anchor.
|
||||
const previous = byGroup.get(group.processGroupId);
|
||||
const members = new Map(previous?.members.map(member => [member.pid, member]) ?? []);
|
||||
for (const member of group.members) members.set(member.pid, member);
|
||||
byGroup.set(group.processGroupId, { ...group,
|
||||
depth: Math.max(group.depth, previous?.depth ?? group.depth),
|
||||
members: [...members.values()],
|
||||
});
|
||||
if (covered.has(next.find(row => row.pid === pid)!.processGroupId)) covered.add(group.processGroupId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { explicitlyRequestsFileOutput } from "../../server/src/services/native-runtime/native-deliverable-feedback.js";
|
||||
import { copilotProtectionTasks } from "./copilot-protection-tasks.js";
|
||||
import type { APIRequestContext, APIResponse } from "@playwright/test";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { classifyFailure } from "./failure-classifier.js";
|
||||
@@ -48,6 +49,27 @@ it("keeps negated bootstrap instructions out of the production file-delivery con
|
||||
expect(explicitlyRequestsFileOutput(`${prompt}\nCreate a downloadable report.txt file.`)).toBe(true);
|
||||
});
|
||||
|
||||
it("delivers the required direct Copilot context tool guidance before publishing native actions", () => {
|
||||
const h = harness();
|
||||
const prompt = createRemoteNativeBootstrap({ ...h.input, requireCopilotTaskContext: true }, h.bind).prompt("copilot");
|
||||
expect(prompt).toContain("get_task_context tool is already available directly");
|
||||
expect(prompt).toContain("get_task_context exactly once");
|
||||
expect(prompt).toContain("Do not call search_api or use call_api or an HTTP route as a substitute");
|
||||
expect(explicitlyRequestsFileOutput(prompt)).toBe(false);
|
||||
expect(h.bootstrap.prompt("other-provider")).not.toContain("get_task_context");
|
||||
});
|
||||
|
||||
it("keeps async completion immediate with a private sentinel and no delivery wait", () => {
|
||||
const task = copilotProtectionTasks.find(task => task.id === "attached-async-settlement")!;
|
||||
const action = task.buildPrompt("nonce");
|
||||
expect(explicitlyRequestsFileOutput(action)).toBe(false);
|
||||
expect(explicitlyRequestsFileOutput(`${harness().bootstrap.prompt("nonce")}\n${action}`)).toBe(false);
|
||||
expect(action).toContain("attempt to finish immediately without calling read_bash or another waiting tool");
|
||||
expect(action).toContain("Do not modify fixture code, manufacture its result, or start another command.");
|
||||
expect(action).toContain("private diagnostic sentinel, not a requested file deliverable");
|
||||
expect(action).toContain("evidence [], verification []");
|
||||
expect(action).not.toContain("register_deliverable");
|
||||
});
|
||||
|
||||
it("keeps the complete readiness/install reserve when a lease arrives at the admission boundary", async () => {
|
||||
vi.useFakeTimers(); vi.setSystemTime(0);
|
||||
|
||||
@@ -63,6 +63,7 @@ export async function createRemoteFixtureClient(apiKey: string): Promise<RemoteF
|
||||
export function createRemoteNativeBootstrap(input: {
|
||||
api: RemoteFixtureApi; daytona: RemoteFixtureDaytona; companyId: string; environmentId: string;
|
||||
agentId: string; image: string; nodeSha256: string; runnerdSha256: string; deadlineAt: number;
|
||||
requireCopilotTaskContext?: boolean;
|
||||
evidence(name: string, data: unknown): Promise<void>;
|
||||
}, bind = bindRemoteNativeFixture): RemoteNativeBootstrap {
|
||||
if (!/^sha256:[a-f0-9]{64}$/u.test(input.nodeSha256) || !/^sha256:[a-f0-9]{64}$/u.test(input.runnerdSha256) || !/@sha256:[a-f0-9]{64}$/u.test(input.image)) {
|
||||
@@ -77,6 +78,10 @@ export function createRemoteNativeBootstrap(input: {
|
||||
return [
|
||||
`Your task instructions will be published by the operator in the workspace file ${path}.`,
|
||||
"Use your native file-read tool to read that exact relative file in the current execution workspace. If it is not present yet, retry the read for up to 20 seconds, then report the setup failure.",
|
||||
...(input.requireCopilotTaskContext ? [
|
||||
"The dedicated Paperclip get_task_context tool is already available directly. Read this setup file first, then invoke get_task_context exactly once and wait for its successful returned context before performing the supplied native command or edit.",
|
||||
"No discovery is needed. search_api discovers HTTP routes, not semantic tools. Do not call search_api or use call_api or an HTTP route as a substitute for get_task_context.",
|
||||
] : []),
|
||||
"Before reading those instructions: do not infer the task. Do not run shell commands. Do not create or modify any file.",
|
||||
"Do not ask replacement questions. Do not mark work complete. Do not create the missing instruction file.",
|
||||
"After reading the complete file, perform precisely the supplied task. Treat it as the operator's continuation of this task.",
|
||||
@@ -183,6 +188,7 @@ export function createRemoteNativeBootstrap(input: {
|
||||
fixture = await bind({ api: input.api, daytona: input.daytona, sdkVersion: "0.203.0", nodeSha256: input.nodeSha256, runnerdSha256: input.runnerdSha256,
|
||||
authority: { companyId: input.companyId, environmentId: input.environmentId, runId: request.runId, leaseId: lease.id, sandboxId: lease.providerLeaseId, image: input.image },
|
||||
targets: [...request.targets], crossRoot: request.crossRoot, actionFile: setup.path, deadlineAt: input.deadlineAt,
|
||||
retainTerminalDiagnostics: diagnostics => input.evidence(`remote-native-terminal-diagnostics-${request.runId}.json`, diagnostics),
|
||||
});
|
||||
} catch (error) {
|
||||
if (lastReadError !== undefined && lastState.phase === "lease_admission") {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createHash } from "node:crypto";
|
||||
import { copilotDeathArguments } from "./copilot-provider-death.js";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdtemp, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -232,6 +233,24 @@ describe("remote native lease admission", () => {
|
||||
if (type === "bad-file") end.targets["result.txt"] = { ...end.targets["result.txt"], absent: false, sha256: hash("missing") };
|
||||
h.resolveTerminal(end); await expect(f.finish()).rejects.toThrow();
|
||||
});
|
||||
it("retains bounded incomplete retirement diagnostics while rejecting the receipt", async () => {
|
||||
const h = harness(), retain = vi.fn(async () => {});
|
||||
const f = await bindRemoteNativeFixture({ ...h.options, retainTerminalDiagnostics: retain });
|
||||
await f.publishAction("action.txt", "task");
|
||||
const end: any = { ...structuredClone(h.current), complete: false, incompleteReasons: ["workspace_directory_added"], processes: { ...h.current.processes, live: [] }, files: {} };
|
||||
h.resolveTerminal(end);
|
||||
await expect(f.finish()).rejects.toThrow("terminal_evidence_incomplete");
|
||||
expect(retain).toHaveBeenCalledExactlyOnceWith({ actionPublished: true, setupPublished: true, complete: false, watcherComplete: true, targetMutationCount: 0, workspaceMutationCount: 0, processRootCaptured: true, liveProcessCount: 0, incompleteReasons: ["workspace_directory_added"] });
|
||||
await expect(f.readFile("result.txt")).rejects.toThrow();
|
||||
});
|
||||
it("rejects arbitrary diagnostic text before retaining terminal metadata", async () => {
|
||||
const h = harness(), retain = vi.fn(async () => {});
|
||||
const f = await bindRemoteNativeFixture({ ...h.options, retainTerminalDiagnostics: retain });
|
||||
await f.publishAction("action.txt", "task");
|
||||
h.resolveTerminal({ ...structuredClone(h.current), incompleteReasons: ["untrusted provider content"], processes: { ...h.current.processes, live: [] }, files: {} } as any);
|
||||
await expect(f.finish()).rejects.toThrow("incomplete_reason_shape");
|
||||
expect(retain).not.toHaveBeenCalled();
|
||||
});
|
||||
it("reports only closed terminal failure reasons without weakening retirement proof", async () => {
|
||||
const h = harness(), f = await bindRemoteNativeFixture(h.options);
|
||||
await f.publishAction("action.txt", "task");
|
||||
@@ -334,10 +353,14 @@ describe("cell deadline and cleanup bounds", () => {
|
||||
});
|
||||
|
||||
describe("independent filesystem and process observations", () => {
|
||||
it.skipIf(process.platform !== "linux")("retains transient create/delete events and detects same-path parent replacement", async () => {
|
||||
it.runIf(process.platform === "linux")("retains observed create/delete events and detects same-path parent replacement", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "remote-watch-")); const watcher = createRemoteTargetWatch(dir, "denied.txt");
|
||||
try {
|
||||
await writeFile(join(dir, "denied.txt"), "forbidden"); await rm(join(dir, "denied.txt"));
|
||||
await writeFile(join(dir, "denied.txt"), "forbidden");
|
||||
// macOS coalesces a create/remove pair before delivering fs.watch. Wait
|
||||
// for the real create observation, then prove it survives removal.
|
||||
await vi.waitFor(() => expect(watcher.snapshot().mutationCount).toBeGreaterThan(0));
|
||||
await rm(join(dir, "denied.txt"));
|
||||
await vi.waitFor(() => expect(watcher.snapshot().mutationCount).toBeGreaterThan(0));
|
||||
expect(watcher.snapshot().complete).toBe(true);
|
||||
await rename(dir, `${dir}-old`); await writeFile(dir, "replacement");
|
||||
@@ -379,6 +402,7 @@ describe("actual generated observer state machine", () => {
|
||||
const missing = () => { throw Object.assign(new Error("missing"), { code: "ENOENT" }); };
|
||||
const fds = new Map<number, string>(); let nextFd = 50, runtimeInode = 4n;
|
||||
const symbolicLinks = new Set<string>();
|
||||
const processExecutables = new Map<string, { path: string; dev: bigint; ino: bigint }>();
|
||||
const directories = new Set(["/tmp", "/workspace", "/workspace/.paperclip-runtime", "/workspace/.paperclip-runtime/paperclip-runner", "/workspace/.paperclip-runtime/paperclip-runner/sessions"]);
|
||||
if (!deferStartup) directories.add(config.root);
|
||||
const listeners = new Map<string, (socket: any) => void>();
|
||||
@@ -404,7 +428,8 @@ describe("actual generated observer state machine", () => {
|
||||
if (!directory && !files.has(path) && !symbolicLinks.has(path)) return missing();
|
||||
return { dev: 1n, ino: path === config.root ? 2n : path === "/workspace/.paperclip-runtime/paperclip-runner" ? runtimeInode : 3n, mtimeNs: 4n, ctimeNs: 5n, isDirectory: () => directory, isFile: () => !directory, isSymbolicLink: () => symbolicLinks.has(path), size: files.get(path)?.length ?? 0 };
|
||||
},
|
||||
realpathSync: (path: string) => path,
|
||||
realpathSync: (path: string) => processExecutables.get(path)?.path ?? (/^\/proc\/\d+\/exe$/u.test(path) ? proc.get(Number(path.split("/")[2]))?.argv[0] : path),
|
||||
statSync(path: string) { const identity = processExecutables.get(path); if (!identity) return missing(); return identity; },
|
||||
readdirSync(path: string) { if (path === "/proc") return [...proc.keys()].map(String); if (path === "/workspace") return [".paperclip-runtime", ...[...files.keys(), ...symbolicLinks].filter(p => p.startsWith("/workspace/") && !p.slice(11).includes("/")).map(p => p.slice(11))]; if (path === "/workspace/.paperclip-runtime") return ["reusable-sandbox-lease.json", "paperclip-runner", ...[...files.keys()].filter(p => p.startsWith(path + "/") && !p.slice(path.length + 1).includes("/") && !p.endsWith("reusable-sandbox-lease.json")).map(p => p.slice(path.length + 1))]; if (path.startsWith("/workspace/.paperclip-runtime/paperclip-runner")) throw new Error("excluded runtime must not be traversed"); return []; },
|
||||
watch(path: string, options: unknown, callback?: (_kind: string, name: string | null) => void) {
|
||||
const entry = { path, callback: (callback ?? options) as (_kind: string, name: string | null) => void, closed: false }; watches.push(entry);
|
||||
@@ -434,7 +459,7 @@ describe("actual generated observer state machine", () => {
|
||||
const net = { createServer(fn: (socket: any) => void) { handlers.push(fn); return server; } };
|
||||
const context = {
|
||||
require(name: string) { if (name === "node:fs") return fs; if (name === "node:net") return net; if (name === "node:child_process") return { spawn: vi.fn(() => { const child = Object.assign(new EventEmitter(), { pid: 88, exitCode: null, signalCode: null, kill: vi.fn() }); children.push(child); return child; }) }; if (name === "node:path") return { join: (...paths: string[]) => paths.join("/"), dirname: (path: string) => path.slice(0, path.lastIndexOf("/")), basename: (path: string) => path.slice(path.lastIndexOf("/") + 1) }; if (name === "node:crypto") return { createHash }; throw new Error("unexpected module"); },
|
||||
process: { argv: ["node", `${config.root}/observer.cjs`, Buffer.from(JSON.stringify(config)).toString("base64")], execPath: "/node", hrtime: { bigint: () => 12345n }, exit: vi.fn() },
|
||||
process: { argv: ["node", `${config.root}/observer.cjs`, Buffer.from(JSON.stringify(config)).toString("base64")], execPath: "/node", hrtime: { bigint: () => 12345n }, exit: vi.fn(), kill: vi.fn() },
|
||||
Buffer, __filename: `${config.root}/observer.cjs`,
|
||||
setInterval(fn: () => void) { intervals.push(fn); return 1; }, clearInterval: vi.fn(),
|
||||
setTimeout(fn: () => void, ms: number) { timers.push({ fn, ms }); return { unref() {} }; },
|
||||
@@ -445,7 +470,7 @@ describe("actual generated observer state machine", () => {
|
||||
const replies: any[] = [], socket = Object.assign(new EventEmitter(), { end: (value: string) => replies.push(JSON.parse(value)), destroy: vi.fn() });
|
||||
handlers[0]!(socket); socket.emit("data", Buffer.from(JSON.stringify({ op, nonce: config.nonce, ...args }) + "\n")); return replies;
|
||||
}
|
||||
return { request, proc, files, watches, fs, intervals, timers, config, handlers, children, symbolicLinks, context, server, directories, listeners, install: h.calls.find(c => c.request.op === "install")!, replaceRuntimeRoot() { runtimeInode = 999n; } };
|
||||
return { request, proc, files, watches, fs, intervals, timers, config, handlers, children, symbolicLinks, processExecutables, context, server, directories, listeners, install: h.calls.find(c => c.request.op === "install")!, replaceRuntimeRoot() { runtimeInode = 999n; } };
|
||||
}
|
||||
async function generatedRpc(o: Awaited<ReturnType<typeof observerHarness>>, request: Record<string, unknown>, mutateSource = (source: string) => source) {
|
||||
const quoted = o.install.command.match(/ -e (.+) '[A-Za-z0-9+/=]+'$/su)![1]!;
|
||||
@@ -626,6 +651,25 @@ describe("actual generated observer state machine", () => {
|
||||
o.proc.set(1, { ppid: 0, group: 1, ticks: "1", argv: ["/sbin/init"] });
|
||||
expect(o.request("snapshot")[0].result.processes.captured).toBe(true);
|
||||
});
|
||||
it.each(["valid", "wrong-inode", "foreign-executable", "ambiguous", "wrong-arguments"])("dispatches generated provider death only for verified descriptor ownership: %s", async variant => {
|
||||
const o = await observerHarness();
|
||||
o.request("snapshot"); o.request("publish", { path: "action.txt", text: "do task" });
|
||||
const executable = "/tmp/paperclip-acpx-native-owned/distribution/copilot";
|
||||
o.proc.set(22, { ppid: 21, group: 21, ticks: "200", argv: ["/node"] });
|
||||
o.proc.set(23, { ppid: 22, group: 21, ticks: "300", argv: ["/proc/self/fd/7", ...copilotDeathArguments] });
|
||||
o.processExecutables.set("/proc/23/exe", { path: executable, dev: 1n, ino: 40n });
|
||||
o.processExecutables.set("/proc/23/fd/7", { path: executable, dev: 1n, ino: 40n });
|
||||
if (variant === "wrong-inode") o.processExecutables.get("/proc/23/fd/7")!.ino = 41n;
|
||||
if (variant === "foreign-executable") { o.processExecutables.get("/proc/23/exe")!.path = "/usr/bin/copilot"; o.processExecutables.get("/proc/23/fd/7")!.path = "/usr/bin/copilot"; }
|
||||
if (variant === "wrong-arguments") o.proc.get(23)!.argv.push("--allow-all");
|
||||
if (variant === "ambiguous") o.proc.set(24, { ppid: 22, group: 21, ticks: "400", argv: [executable, ...copilotDeathArguments] });
|
||||
const replies = o.request("provider-death");
|
||||
if (variant === "valid") {
|
||||
expect(replies[0]).toMatchObject({ ok: true, result: { schema: "paperclip.e2e.copilot-owned-provider-death.v1", signal: "SIGKILL", child: { pid: 23 } } });
|
||||
expect(o.context.process.kill).toHaveBeenCalledExactlyOnceWith(23, "SIGKILL");
|
||||
expect(o.request("provider-death")[0].ok).toBe(false);
|
||||
} else { expect(replies[0].ok).toBe(false); expect(o.context.process.kill).not.toHaveBeenCalled(); }
|
||||
});
|
||||
it("arms exact remote root then seals drained no-live proof and retained bytes", async () => {
|
||||
const o = await observerHarness(); expect(o.request("snapshot")[0].result.processes.captured).toBe(true);
|
||||
const wait = o.request("wait"); expect(wait).toHaveLength(0);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { canonicalRemoteCopilotCommand, copilotDeathArguments, selectOwnedCopilotProcess } from "./copilot-provider-death.js";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { watch, lstatSync } from "node:fs";
|
||||
import { posix } from "node:path";
|
||||
@@ -30,7 +31,7 @@ function relative(value: string): string {
|
||||
}
|
||||
// Only closed diagnostic enums cross the remote boundary; SDK errors and output
|
||||
// are never retained. These diagnostics explain failed evidence, not qualification.
|
||||
const RPC_PHASES = ["runtime-ready", "install", "wait", "close", "arm", "publish", "snapshot", "attached", "read", "inject-loss"] as const;
|
||||
const RPC_PHASES = ["runtime-ready", "install", "wait", "close", "arm", "publish", "snapshot", "attached", "read", "inject-loss", "provider-death"] as const;
|
||||
const RPC_CODES = ["node_identity", "sentinel_type", "sentinel", "cwd", "runtime_root_identity", "runtime_binary_identity", "proc_bound", "ambiguous_run_root", "runtime_not_ready", "runtime_identity_changed", "invalid_proc_identity", "invalid_proc_fields", "socket_error", "socket_timeout", "output_bound", "rpc_deadline", "remote_unknown", "transport_failure", "invalid_response", "readiness_deadline", "terminal_evidence_incomplete"] as const;
|
||||
type RpcPhase = typeof RPC_PHASES[number];
|
||||
type RpcCode = typeof RPC_CODES[number];
|
||||
@@ -48,12 +49,15 @@ export function remoteNativeFixtureDiagnostics(error: unknown): RpcDiagnostic[]
|
||||
export interface RemoteNativeAuthority { companyId: string; environmentId: string; runId: string; leaseId: string; sandboxId: string; image: string }
|
||||
export interface RemoteNativeBinding extends RemoteNativeAuthority { remoteCwd: string }
|
||||
export interface RemoteProcessIdentity { pid: number; ppid: number; startTicks: string; bootId: string }
|
||||
const INCOMPLETE_REASONS = ["multiple_run_roots", "run_root_changed", "process_identity_reused", "workspace_watch_incomplete", "setup_file_changed", "workspace_event_bound", "workspace_directory_added", "retirement_snapshot_failed", "process_sample_failed", "control_bound", "observer_ttl"] as const;
|
||||
|
||||
export interface RemoteNativeSnapshot {
|
||||
binding: RemoteNativeBinding;
|
||||
observedAtMs: number;
|
||||
observedMonotonicNs: string;
|
||||
receivedAtMs: number;
|
||||
complete: boolean;
|
||||
incompleteReasons?: readonly string[];
|
||||
workspace: Record<string, string>;
|
||||
targets: Record<string, { absent: boolean; sha256: string | null; parent: { dev: string; ino: string }; mutationCount: number; complete: boolean }>;
|
||||
watcher: { complete: boolean; targetMutationCount: number; workspaceMutationCount: number };
|
||||
@@ -121,15 +125,15 @@ export function createRemoteTargetWatch(directory: string, name: string, io = {
|
||||
const OBSERVER = String.raw`
|
||||
const fs=require('node:fs'),path=require('node:path'),crypto=require('node:crypto'),net=require('node:net'),cp=require('node:child_process');
|
||||
const config=JSON.parse(Buffer.from(process.argv[2],'base64').toString());
|
||||
const parseStat=PARSE_STAT;const runRoot=RUN_ROOT;const watchTarget=WATCH_TARGET;
|
||||
const parseStat=PARSE_STAT;const runRoot=RUN_ROOT;const watchTarget=WATCH_TARGET;const selectCopilot=SELECT_COPILOT;const canonicalCopilot=CANONICAL_COPILOT;const copilotArgs=COPILOT_ARGS;let providerDeathDispatched=false;
|
||||
const hash=x=>'sha256:'+crypto.createHash('sha256').update(x).digest('hex');
|
||||
const cwdStat=fs.lstatSync(config.binding.remoteCwd,{bigint:true}),rootStat=fs.lstatSync(config.root,{bigint:true}),scriptStat=fs.lstatSync(__filename,{bigint:true}),scriptHash=hash(fs.readFileSync(__filename));
|
||||
const runtimeRoot=path.join(config.binding.remoteCwd,config.runtimeRelative),runtimeStat=fs.lstatSync(runtimeRoot,{bigint:true});if(!runtimeStat.isDirectory()||runtimeStat.isSymbolicLink()||fs.realpathSync(runtimeRoot)!==runtimeRoot)throw Error('runtime_root_identity');let observedPrpEnvironmentLeaseId=null;
|
||||
const boot=fs.readFileSync('/proc/sys/kernel/random/boot_id','utf8').trim();
|
||||
const targets=new Map();let complete=true,sealed=false,root=null,attached=null,child=null,client=null,childTimer=null;
|
||||
const journal=new Map(),sockets=new Set(),waiters=new Set(),armWaiters=new Set(),failureCodes=new Set();let observedRootCount=0,publishedHash=null,finalReceipt=null,retiring=false;
|
||||
function incomplete(code){complete=false;failureCodes.add(code)}
|
||||
const targets=new Map();let complete=true,sealed=false,root=null,attached=null,child=null,client=null,childTimer=null;const incompleteReasons=new Set();const failureCodes=new Set();function incomplete(code){complete=false;failureCodes.add(code)}function markIncomplete(reason){complete=false;incompleteReasons.add(reason)}
|
||||
const journal=new Map(),sockets=new Set(),waiters=new Set(),armWaiters=new Set();let observedRootCount=0,publishedHash=null,finalReceipt=null,retiring=false;
|
||||
function identity(pid){return parseStat(pid,fs.readFileSync('/proc/'+pid+'/stat','utf8'),boot)}
|
||||
function deathCommand(pid){const command=fs.readFileSync('/proc/'+pid+'/cmdline').toString().split('\0').filter(Boolean);if(!/^\/proc\/self\/fd\/(?:3|7)$/.test(command[0]??''))return command;const executable=fs.realpathSync('/proc/'+pid+'/exe'),s=fs.statSync('/proc/'+pid+'/exe',{bigint:true}),fd='/proc/'+pid+'/fd/'+command[0].split('/').at(-1),d=fs.statSync(fd,{bigint:true});return canonicalCopilot(command,executable,{dev:String(s.dev),ino:String(s.ino)},{path:fs.realpathSync(fd),dev:String(d.dev),ino:String(d.ino)})}
|
||||
function table(){const ids=fs.readdirSync('/proc').filter(x=>/^\d+$/.test(x)&&Number(x)>1);if(ids.length>4096)throw Error('process_bound');return ids.flatMap(x=>{try{return [identity(Number(x))]}catch(e){if(e.code==='ENOENT'||e.code==='ESRCH')return [];throw e}})}
|
||||
function sample(){
|
||||
const all=table();const candidates=[];
|
||||
@@ -150,7 +154,7 @@ function guard(){const rs=fs.lstatSync(runtimeRoot,{bigint:true});if(!rs.isDirec
|
||||
function readSafe(p){const fd=fs.openSync(p,fs.constants.O_RDONLY|fs.constants.O_NOFOLLOW);try{const before=fs.fstatSync(fd,{bigint:true});if(!before.isFile()||before.size>65536n)throw Error('file_bound_or_type');const bytes=fs.readFileSync(fd),after=fs.fstatSync(fd,{bigint:true}),named=fs.lstatSync(p,{bigint:true});if(before.dev!==named.dev||before.ino!==named.ino||named.isSymbolicLink()||before.size!==after.size||before.mtimeNs!==after.mtimeNs||BigInt(bytes.length)!==before.size)throw Error('file_changed');return bytes}finally{fs.closeSync(fd)}}
|
||||
function file(p){try{const s=fs.lstatSync(p);if(s.isSymbolicLink()||!s.isFile()||s.size>65536)throw Error('file_bound_or_type');return {absent:false,sha256:hash(readSafe(p))}}catch(e){if(e.code==='ENOENT')return {absent:true,sha256:null};throw e}}
|
||||
function workspace(){const result={};let count=0,bytes=0;function visit(dir,prefix){for(const name of fs.readdirSync(dir).sort()){if(++count>512)throw Error('workspace_entry_bound');const full=path.join(dir,name),rel=prefix+name,s=fs.lstatSync(full);if(rel===config.runtimeRelative)continue;if(rel===config.actionFile){if(!publishedHash||file(full).sha256!==publishedHash)throw Error('setup_file_changed');continue;}if(s.isSymbolicLink())throw Error('workspace_symlink');if(s.isDirectory()){result[rel]='directory';visit(full,rel+'/')}else if(s.isFile()){bytes+=s.size;if(s.size>65536||bytes>4194304)throw Error('workspace_byte_bound');result[rel]=hash(readSafe(full))}else throw Error('workspace_special_file')}}visit(config.binding.remoteCwd,'');return result}
|
||||
function snapshot(){guard();verifyWorkspaceWatchRoots();const processes=sample(),out={};let watchComplete=complete,total=0;for(const [name,t] of targets){const status=t.watch.snapshot();out[name]={...file(t.path),...status};watchComplete&&=status.complete;total+=status.mutationCount}return {binding:config.binding,observedAtMs:Date.now(),observedMonotonicNs:process.hrtime.bigint().toString(),complete:complete&&watchComplete,failureCodes:[...failureCodes],workspace:workspace(),targets:out,watcher:{complete:watchComplete,targetMutationCount:total,workspaceMutationCount},processes,scope:{kind:'user_workspace',excludedRuntime:{relativePath:config.runtimeRelative,absolutePath:runtimeRoot,dev:String(runtimeStat.dev),ino:String(runtimeStat.ino),runnerExecutableSha256:config.runnerdSha256},observedPrpEnvironmentLeaseId,prpEnvironmentLeaseIdVerified:false},setup:{path:config.actionFile,sha256:publishedHash,published:publishedHash!==null},attached}}
|
||||
function snapshot(){guard();verifyWorkspaceWatchRoots();const processes=sample(),out={};let watchComplete=complete,total=0;for(const [name,t] of targets){const status=t.watch.snapshot();out[name]={...file(t.path),...status};watchComplete&&=status.complete;total+=status.mutationCount}return {binding:config.binding,observedAtMs:Date.now(),observedMonotonicNs:process.hrtime.bigint().toString(),complete:complete&&watchComplete,failureCodes:[...failureCodes],incompleteReasons:[...incompleteReasons],workspace:workspace(),targets:out,watcher:{complete:watchComplete,targetMutationCount:total,workspaceMutationCount},processes,scope:{kind:'user_workspace',excludedRuntime:{relativePath:config.runtimeRelative,absolutePath:runtimeRoot,dev:String(runtimeStat.dev),ino:String(runtimeStat.ino),runnerExecutableSha256:config.runnerdSha256},observedPrpEnvironmentLeaseId,prpEnvironmentLeaseIdVerified:false},setup:{path:config.actionFile,sha256:publishedHash,published:publishedHash!==null},attached}}
|
||||
for(const name of config.targets){const p=path.join(config.binding.remoteCwd,name);if(fs.realpathSync(path.dirname(p))!==path.dirname(p))throw Error('target_parent_symlink');targets.set(name,{path:p,watch:watchTarget(path.dirname(p),path.basename(p),{watch:fs.watch,lstatSync:fs.lstatSync})})}
|
||||
if(config.crossRoot){const p=path.join(config.root,'cross-root-target');fs.writeFileSync(p,config.crossRoot.initialText,{flag:'wx',mode:0o600});targets.set('@cross-root',{path:p,watch:watchTarget(config.root,'cross-root-target',{watch:fs.watch,lstatSync:fs.lstatSync})})}
|
||||
let workspaceMutationCount=0;const directoryWatches=[];
|
||||
@@ -175,6 +179,16 @@ const server=net.createServer(socket=>{sockets.add(socket);socket.on('close',()=
|
||||
child=cp.spawn(process.execPath,['-e','setTimeout(()=>process.exit(0),'+r.delayMs+')'],{env:{PATH:'/usr/bin:/bin'},stdio:'ignore'});child.once('error',()=>{attached.failure='child_start';s.destroy()});child.once('exit',(code,signal)=>{attached.commandExit={code:code??-1,observedAtMs:Date.now(),observedMonotonicNs:process.hrtime.bigint().toString()};if(code!==0||signal){attached.failure='child_failed';s.destroy();return}try{fs.writeFileSync(path.join(config.binding.remoteCwd,r.marker),r.markerText,{flag:'wx'});attached.markerWrittenAtMs=Date.now();attached.markerWrittenMonotonicNs=process.hrtime.bigint().toString();s.end(JSON.stringify({code:0})+'\n')}catch{attached.failure='marker_failed';s.destroy()}});
|
||||
}catch{attached.failure='client_rejected';s.destroy()}})});srv.listen(clientSocket);attached.server=srv;result={clientScript,clientSocket};
|
||||
}
|
||||
else if(r.op==='provider-death'){
|
||||
if(providerDeathDispatched||!publishedHash||sealed)throw Error('death_boundary');
|
||||
const observed=sample();if(!complete||!observed.captured)throw Error('death_owner');
|
||||
const argv=new Map(observed.live.map(pid=>[pid,deathCommand(pid)]));
|
||||
const selected=selectCopilot(root.pid,observed.journal.map(p=>({...p,parent:p.ppid})),observed.live,argv,copilotArgs);
|
||||
const current=identity(selected.pid),currentRoot=identity(root.pid),command=deathCommand(selected.pid);
|
||||
if(current.bootId!==selected.bootId||current.startTicks!==selected.startTicks||currentRoot.startTicks!==root.startTicks||JSON.stringify(command)!==JSON.stringify(argv.get(selected.pid))||fs.realpathSync('/proc/'+selected.pid+'/exe')!==command[0])throw Error('death_identity');
|
||||
providerDeathDispatched=true;const dispatchedMonotonicNs=process.hrtime.bigint().toString();process.kill(selected.pid,'SIGKILL');
|
||||
result={schema:'paperclip.e2e.copilot-owned-provider-death.v1',root,child:current,signal:'SIGKILL',commandSha256:hash(JSON.stringify(command)),dispatchedMonotonicNs};
|
||||
}
|
||||
else if(r.op==='inject-loss'){const p=sample();if(sealed||!complete||!publishedHash||!root||!p.live.includes(root.pid))throw Error('loss_owner_missing');const original=journal.get(root.pid);if(!original||original.startTicks!==root.startTicks||original.bootId!==root.bootId)throw Error('loss_owner_changed');
|
||||
const program="import os,signal,sys\npid=int(sys.argv[1]);fd=os.pidfd_open(pid)\ntry:\n s=open('/proc/'+str(pid)+'/stat').read();ticks=s[s.rfind(') ')+2:].split()[19];boot=open('/proc/sys/kernel/random/boot_id').read().strip()\n if ticks!=sys.argv[2] or boot!=sys.argv[3]:raise RuntimeError('identity changed')\n signal.pidfd_send_signal(fd,signal.SIGKILL)\nfinally:os.close(fd)";
|
||||
const killed=cp.spawnSync('/usr/bin/python3',['-c',program,String(root.pid),root.startTicks,root.bootId],{env:{PATH:'/usr/bin:/bin'},stdio:'pipe',timeout:3000});if(killed.status!==0)throw Error('loss_not_injected');result={root:original,signal:'SIGKILL',observedMonotonicNs:process.hrtime.bigint().toString()};}
|
||||
@@ -186,12 +200,12 @@ const server=net.createServer(socket=>{sockets.add(socket);socket.on('close',()=
|
||||
socket.end(JSON.stringify({ok:true,result})+'\n');
|
||||
}catch{socket.end(JSON.stringify({ok:false,error:'observer_evidence_incomplete'})+'\n')}})});
|
||||
let closing=false;async function shutdown(reply){if(closing){reply?.end(JSON.stringify({ok:false,error:'closing'})+'\n');return}closing=true;sealed=true;workspaceWatch.close();for(const t of targets.values())t.watch.close();clearInterval(observer);let settled=true;try{if(child&&child.exitCode===null&&child.signalCode===null){child.kill('SIGTERM');const exited=new Promise(resolve=>child.once('exit',resolve));await Promise.race([exited,new Promise(resolve=>setTimeout(resolve,2000))]);if(child.exitCode===null&&child.signalCode===null){child.kill('SIGKILL');await Promise.race([exited,new Promise(resolve=>setTimeout(resolve,2000))]);}settled=child.exitCode!==null||child.signalCode!==null;}if(reply)reply.end(JSON.stringify({ok:settled,result:{closed:settled}})+'\n');}finally{for(const s of sockets)if(s!==reply)s.destroy();server.close();if(attached?.server)attached.server.close();setTimeout(()=>{reply?.destroy();if(settled){const current=fs.lstatSync(config.root,{bigint:true});if(current.dev===rootStat.dev&¤t.ino===rootStat.ino&&!current.isSymbolicLink())fs.rmSync(config.root,{recursive:true,force:true});else settled=false;}process.exit(settled?0:2)},100)}}
|
||||
server.listen(path.join(config.root,'control.sock'));setTimeout(()=>{complete=false;shutdown(null)},config.observerTtlMs).unref();
|
||||
server.listen(path.join(config.root,'control.sock'));setTimeout(()=>{markIncomplete("observer_ttl");shutdown(null)},config.observerTtlMs).unref();
|
||||
`;
|
||||
const ATTACHED_CLIENT = String.raw`const net=require('node:net');const s=net.connect(process.argv[2]);let b='';s.setTimeout(15000,()=>process.exit(3));s.on('error',()=>process.exit(4));s.on('connect',()=>s.write(JSON.stringify({nonce:process.argv[3],pid:process.pid})+'\n'));s.on('data',x=>{b+=x;if(b.length>1024)process.exit(6);if(b.includes('\n')){const r=JSON.parse(b);s.end();process.exit(r.code===0?0:5)}});`;
|
||||
function observerSource() {
|
||||
return OBSERVER.replace("PARSE_STAT", () => parseRemoteProcStat.toString()).replace("RUN_ROOT", () => isRemoteRunRoot.toString())
|
||||
.replace("WATCH_TARGET", () => createRemoteTargetWatch.toString()).replace("ATTACHED_CLIENT", () => JSON.stringify(ATTACHED_CLIENT));
|
||||
.replace("SELECT_COPILOT", () => selectOwnedCopilotProcess.toString()).replace("CANONICAL_COPILOT", () => canonicalRemoteCopilotCommand.toString()).replace("COPILOT_ARGS", () => JSON.stringify(copilotDeathArguments)).replace("WATCH_TARGET", () => createRemoteTargetWatch.toString()).replace("ATTACHED_CLIENT", () => JSON.stringify(ATTACHED_CLIENT));
|
||||
}
|
||||
const RPC = String.raw`const fs=require('node:fs'),net=require('node:net'),cp=require('node:child_process'),crypto=require('node:crypto');const r=JSON.parse(Buffer.from(process.argv[1],'base64').toString());const hash=x=>'sha256:'+crypto.createHash('sha256').update(x).digest('hex');
|
||||
const startedAt=Date.now();if(!Number.isInteger(r.timeoutMs)||r.timeoutMs<1000||r.timeoutMs>300000)throw Error('rpc_deadline');setTimeout(()=>{failure('rpc_deadline');process.exit(2)},r.timeoutMs).unref();
|
||||
@@ -219,6 +233,7 @@ export interface RemoteNativeFixture {
|
||||
injectOwnedRunLoss?(): Promise<unknown>;
|
||||
readFile(path: string): Promise<Buffer>;
|
||||
publishAction(path: string, text: string): Promise<void>;
|
||||
killOwnedCopilot(): Promise<Record<string, unknown>>;
|
||||
setupAttachedCommand(input: { marker: string; markerText: string; delayMs: number }): Promise<{ command: string; commandSha256: string }>;
|
||||
/** Consumes the host-held terminal receipt; never queries a deleted lease. */
|
||||
finish(): Promise<RemoteNativeSnapshot>;
|
||||
@@ -237,12 +252,14 @@ export interface RemoteNativeFixtureOptions {
|
||||
/** Cell deadline; receipt collection stops 15s earlier for public teardown. */
|
||||
deadlineAt: number;
|
||||
crossRoot?: { initialText: string };
|
||||
retainTerminalDiagnostics?(diagnostics: Record<string, boolean | number | readonly string[]>): Promise<void>;
|
||||
}
|
||||
const sha = (value: unknown): value is string => typeof value === "string" && /^sha256:[a-f0-9]{64}$/u.test(value);
|
||||
function readSnapshot(value: unknown, binding: RemoteNativeBinding, names: string[], actionFile: string, runnerdSha256: string): RemoteNativeSnapshot {
|
||||
const row = record(value), watcher = record(row.watcher), processes = record(row.processes), setup = record(row.setup);
|
||||
fail(JSON.stringify(row.binding) === JSON.stringify(binding), "receipt_binding");
|
||||
fail(Number.isSafeInteger(row.observedAtMs) && (row.observedAtMs as number) > 0 && typeof row.observedMonotonicNs === "string" && /^\d+$/u.test(row.observedMonotonicNs) && typeof row.complete === "boolean", "receipt_shape");
|
||||
fail(row.incompleteReasons === undefined || (Array.isArray(row.incompleteReasons) && row.incompleteReasons.length <= INCOMPLETE_REASONS.length && row.incompleteReasons.every(reason => INCOMPLETE_REASONS.includes(reason as typeof INCOMPLETE_REASONS[number])) && new Set(row.incompleteReasons).size === row.incompleteReasons.length), "incomplete_reason_shape");
|
||||
fail(Object.keys(record(row.workspace)).length <= 512 && Object.entries(record(row.workspace)).every(([path, hash]) => {
|
||||
try { relative(path); return hash === "directory" || sha(hash); } catch { return false; }
|
||||
}), "workspace_shape");
|
||||
@@ -462,6 +479,12 @@ export async function bindRemoteNativeFixture(options: RemoteNativeFixtureOption
|
||||
const ack = record(await rpc({ op: "publish", path, text }));
|
||||
fail(ack.published === true && ack.path === path && ack.sha256 === digest(text), "publish_ack");
|
||||
},
|
||||
async killOwnedCopilot() {
|
||||
fail(!closed && published && !finished, "death_boundary");
|
||||
const result = record(await rpc({ op: "provider-death" }));
|
||||
fail(result.schema === "paperclip.e2e.copilot-owned-provider-death.v1" && result.signal === "SIGKILL" && /^sha256:[a-f0-9]{64}$/u.test(String(result.commandSha256)) && /^\d+$/u.test(String(result.dispatchedMonotonicNs)), "death_ack");
|
||||
return result;
|
||||
},
|
||||
async setupAttachedCommand(input) {
|
||||
fail(!closed && !published && targets.includes(input.marker) && Number.isInteger(input.delayMs) && input.delayMs >= 100 && input.delayMs <= 8000
|
||||
&& typeof input.markerText === "string" && Buffer.byteLength(input.markerText) <= 4096, "attached_bound");
|
||||
@@ -477,6 +500,7 @@ export async function bindRemoteNativeFixture(options: RemoteNativeFixtureOption
|
||||
if ("error" in receipt) throw receipt.error;
|
||||
fail(receipt.receivedAtMs <= receiptDeadlineAt, "receipt_deadline");
|
||||
const result = readSnapshot(receipt.value, binding!, names, actionFile, options.runnerdSha256);
|
||||
await options.retainTerminalDiagnostics?.({ actionPublished: published, setupPublished: result.setup.published, complete: result.complete, watcherComplete: result.watcher.complete, targetMutationCount: result.watcher.targetMutationCount, workspaceMutationCount: result.watcher.workspaceMutationCount, processRootCaptured: result.processes.captured, liveProcessCount: result.processes.live.length, incompleteReasons: result.incompleteReasons ?? [] });
|
||||
if (!(published && result.setup.published && result.complete && result.watcher.complete && result.processes.captured && result.processes.live.length === 0)) {
|
||||
// Closed flags explain missing evidence without retaining SDK output,
|
||||
// provider text, file contents or opaque observer control credentials.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { runNativeActiveStopFlow as runCopilotActiveStopFlow } from "./copilot-active-stop-flow.js";
|
||||
import { runCopilotProtectionFlow } from "./copilot-protection-flow.js";
|
||||
import { nativeActiveStopTasks as copilotActiveStopTasks } from "./copilot-active-stop-tasks.js";
|
||||
import { runPlanTaskFlow } from "./plan-task-flow.js";
|
||||
import { assertNativeCompletionSelection, NATIVE_COMPLETION_PREFLIGHT_ENV, verifyNativeCompletionPreflight } from "./native-completion-admission.js";
|
||||
import { assertNativeInstructionSelection, verifyNativeInstructionPreflight, NATIVE_INSTRUCTION_PREFLIGHT_ENV, NATIVE_INSTRUCTION_SUITE, NATIVE_INSTRUCTION_DEFAULT_SHA256 } from "./native-instruction-consolidation.js";
|
||||
@@ -584,7 +587,7 @@ for (const execution of executions) {
|
||||
const credentials = credentialValues();
|
||||
const secrets = normalizedSecrets(Object.values(credentials));
|
||||
const api = new RunnerApi(request);
|
||||
const companyRunFlow = execution.suite.id === "task-titles" || ["plan_task_guidance", "blocker_guidance", "continuation_accounting", "continuation", "context_integrity", "agent_chat", "everyday_workflow", "first_task", "instruction_persistence", "cursor_native", "native_active_stop", "native_provider_loss", "public_mcp"].includes(execution.task.flow);
|
||||
const companyRunFlow = execution.suite.id === "task-titles" || ["plan_task_guidance", "blocker_guidance", "continuation_accounting", "continuation", "context_integrity", "agent_chat", "everyday_workflow", "first_task", "instruction_persistence", "copilot_protection", "cursor_native", "native_active_stop", "native_provider_loss", "public_mcp"].includes(execution.task.flow);
|
||||
const publicMcpUsage = execution.task.flow === "public_mcp" ? assistantUsage(execution.profile.provider === "claude" ? "anthropic" : "openai", execution.profile.model) : undefined;
|
||||
let publicMcpUserId = "";
|
||||
const consoleDiagnostics: Array<Record<string, unknown>> = [];
|
||||
@@ -903,7 +906,7 @@ for (const execution of executions) {
|
||||
nativeInitial = { issueIds: issues.map(value => value.id), agentIds: agents.map(value => value.id), workspaceDigest };
|
||||
}
|
||||
const remoteBootstrap = execution.environment.id === "daytona"
|
||||
&& ["cursor_native", "native_active_stop", "native_provider_loss"].includes(execution.task.flow)
|
||||
&& ["copilot_protection", "cursor_native", "native_active_stop", "native_provider_loss"].includes(execution.task.flow)
|
||||
? createRemoteNativeBootstrap({
|
||||
api, daytona: await createRemoteFixtureClient(credentials.DAYTONA_API_KEY ?? ""),
|
||||
companyId: fixtures.company.id, environmentId: fixtures.environment.id, agentId: fixtures.agent.id,
|
||||
@@ -911,6 +914,7 @@ for (const execution of executions) {
|
||||
nodeSha256: process.env.PAPERCLIP_E2E_DAYTONA_NODE_SHA256 ?? "",
|
||||
runnerdSha256: process.env.PAPERCLIP_E2E_DAYTONA_RUNNERD_SHA256 ?? "",
|
||||
deadlineAt: startedAtMs + deadlineMs,
|
||||
requireCopilotTaskContext: execution.profile.qualificationCandidate === "copilot",
|
||||
evidence: (name, data) => writeSanitizedJson(snapshotsDir, name, data, secrets),
|
||||
}) : undefined;
|
||||
|
||||
@@ -1022,14 +1026,23 @@ for (const execution of executions) {
|
||||
issue = story.issue as IssueRecord; selectedRuns = story.runs as RunRecord[];
|
||||
matcherResults = story.checks.map(check => ({ matcher: { kind: "json_path" as const, path: `nativeProviderLoss.${check.id}`, expected: true }, passed: check.passed, detail: check.detail }));
|
||||
} else if (execution.task.flow === "native_active_stop") {
|
||||
const story = await runNativeActiveStopFlow({
|
||||
page, api, fixtures, execution, nonce, workspacePath, deadlineAt: startedAtMs + deadlineMs,
|
||||
const story = await (execution.profile.qualificationCandidate === "copilot" ? runCopilotActiveStopFlow : runNativeActiveStopFlow)({
|
||||
page, api, fixtures, execution: execution.profile.qualificationCandidate === "copilot" && execution.task.id === "pending-permission-stop" ? { ...execution, task: copilotActiveStopTasks[0]! } : execution, nonce, workspacePath, deadlineAt: startedAtMs + deadlineMs,
|
||||
observe: (currentIssue, currentRuns) => { issue = currentIssue as IssueRecord; selectedRuns = currentRuns as RunRecord[]; },
|
||||
capture: captureScreenshot, evidence: (name, data) => writeSanitizedJson(snapshotsDir, name, data, secrets),
|
||||
remoteBootstrap, registerCleanupAssertion, registerBeforeEnvironmentTeardownAssertion,
|
||||
});
|
||||
issue = story.issue as IssueRecord; selectedRuns = story.runs as RunRecord[];
|
||||
matcherResults = story.checks.map(check => ({ matcher: { kind: "json_path" as const, path: `nativeActiveStop.${check.id}`, expected: true }, passed: check.passed, detail: check.detail }));
|
||||
} else if (execution.task.flow === "copilot_protection") {
|
||||
const story = await runCopilotProtectionFlow({
|
||||
page, api, fixtures, execution, nonce, workspacePath, deadlineAt: startedAtMs + deadlineMs,
|
||||
observe: (currentIssue, currentRuns) => { issue = currentIssue as IssueRecord; selectedRuns = currentRuns as RunRecord[]; },
|
||||
capture: captureScreenshot, evidence: (name, data) => writeSanitizedJson(snapshotsDir, name, data, secrets),
|
||||
remoteBootstrap, registerBeforeEnvironmentTeardownAssertion,
|
||||
});
|
||||
issue = story.issue as IssueRecord; selectedRuns = story.runs as RunRecord[];
|
||||
matcherResults = story.checks.map(check => ({ matcher: { kind: "json_path" as const, path: `copilotProtection.${check.id}`, expected: true }, passed: check.passed, detail: check.detail }));
|
||||
} else if (execution.task.flow === "cursor_native") {
|
||||
const story = await runCursorNativeFlow({
|
||||
page, api, fixtures, execution, nonce, workspacePath, deadlineAt: startedAtMs + deadlineMs,
|
||||
|
||||
@@ -35,6 +35,7 @@ export type RunnerTaskFlow =
|
||||
| "warm_three_turn"
|
||||
| "instruction_persistence"
|
||||
| "native_active_stop"
|
||||
| "copilot_protection"
|
||||
| "native_provider_loss"
|
||||
| "cursor_native";
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ describe("managed warm file oracle", () => {
|
||||
save: { state: "saved", contract: "agent_files", checkpointStats: { copiedBytes: 16, copiedFiles: 1, hashedBytes: 16 } } };
|
||||
it("accepts independently saved bytes with an incremental receipt", () => expect(gradeWarmManagedFiles(good).passed).toBe(true));
|
||||
it.each([
|
||||
{ content: "T1-test\n" }, { binary: Buffer.from("claimed image") }, { deletedStatus: 200 }, { save: undefined },
|
||||
{ content: "T1-test\n" }, { content: "T1-test\\nT2-test\\n" },
|
||||
{ binary: Buffer.from("claimed image") }, { deletedStatus: 200 }, { save: undefined },
|
||||
{ save: { ...good.save, checkpointStats: { copiedBytes: 8388624, copiedFiles: 2, hashedBytes: 8388624 } } },
|
||||
])("rejects missing, stale, deleted, or fully recopied data", wrong => expect(gradeWarmManagedFiles({ ...good, ...wrong }).passed).toBe(false));
|
||||
});
|
||||
Reference in new issue
Block a user