From fe3b7e6fb8195cf6dae5627f79498b506bebea1d Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 28 Sep 2026 11:28:10 -0500 Subject: [PATCH] Project Copilot native events into attributed runner activity Co-Authored-By: Paperclip --- .../runner-copilot-capabilities.md | 17 +++ .../acpx/copilot-extension-adapter.test.ts | 77 ++++++++++ .../drivers/acpx/copilot-extension-adapter.ts | 136 ++++++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.ts diff --git a/doc/architecture/runner-copilot-capabilities.md b/doc/architecture/runner-copilot-capabilities.md index 5a4f4a727ad..043883921c9 100644 --- a/doc/architecture/runner-copilot-capabilities.md +++ b/doc/architecture/runner-copilot-capabilities.md @@ -67,6 +67,13 @@ commands capable of changing permissions, cwd, remote/export, MCP and schedules; these are not authority to offer an unrestricted command UI. Adversarial workspace/config qualification remains required before release. +The pinned ACP handler maps `allow_always` to native `approve-for-session` for +commands, writes, reads, MCP and other supported tools. Path approval is also +session-scoped; URL approval is session-scoped to an origin pattern. Factory +permissions omit that option and reject fabricated permanent approval. This +scope is confirmed in source, not inferred from the option label. Read/write +session grants are broader than one file and must be described accurately. + ## Negotiation and event contract The observed initialize result advertises protocol 1; `loadSession: true`; @@ -104,6 +111,15 @@ reads, workspace rebinding, permission changes, native-input replies or terminal settlement. Inline binary assets are content-address verified; only metadata is forwarded until a provider-session artifact resolver can upload them safely. +`copilot-extension-adapter.ts` converts the normalized events into canonical +delegation, compaction and unregistered artifact activity. Every safe projected +field is retained in bounded provider-notice details with method/event/session/ +turn provenance. Notices have readable summaries; secret-shaped string values +are scrubbed without erasing numeric token counters. These display events never +create a usage charge, input-resolution acknowledgment, registered artifact, or +turn terminal event. The provider registry installs this factory and initialize +capability metadata in the provider branch. + ## Comparison against Codex app-server “Source” means verified in the pinned implementation; “wire” means observed in @@ -180,6 +196,7 @@ python3 packages/paperclip-runner/scripts/probe-copilot-acp.py --package-root /p python3 packages/paperclip-runner/scripts/probe-copilot-acp.py --package-root /path/to/copilot-darwin-arm64/package --scenario attached-shell node --test packages/paperclip-runner/scripts/materialize-copilot-binary.test.mjs pnpm --filter @paperclipai/paperclip-runner exec vitest run src/drivers/acpx/copilot-events.test.ts src/drivers/acpx/copilot-profile.test.ts src/drivers/acpx/copilot-evidence.test.ts +pnpm --filter @paperclipai/paperclip-runner exec vitest run src/drivers/acpx/copilot-extension-adapter.test.ts ``` Retained real-binary evidence: diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.test.ts new file mode 100644 index 00000000000..20aaa6a65c6 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.test.ts @@ -0,0 +1,77 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { COPILOT_ACP_EVENT_METHOD } from "./copilot-events.js"; +import { createCopilotProfileExtensionAdapter } from "./copilot-extension-adapter.js"; +import { validateAcpxRichEvent } from "./profile-extensions.js"; + +const context = { workspacePath: "/workspace", sessionId: "session-1", turnId: "turn-1" }; +const params = (type: string, data: unknown, extra = {}) => ({ sessionId: context.sessionId, type, data, ...extra }); + +describe("Copilot display extension adapter", () => { + it("preserves subagent lifecycle identity and all safe details with canonical provenance", async () => { + const adapter = createCopilotProfileExtensionAdapter(context); + const started = await adapter.notification(COPILOT_ACP_EVENT_METHOD, params("subagent.started", { agentName: "review", agentDisplayName: "Reviewer", toolCallId: "tool-1", agentDescription: "Review the change", model: "exact-model", resumable: true }, { agentId: "agent-2" })); + const completed = await adapter.notification(COPILOT_ACP_EVENT_METHOD, params("subagent.completed", { agentName: "review", toolCallId: "tool-1", model: "exact-model", totalToolCalls: 3, durationMs: 12.5, totalTokens: 40, modelSelectionSource: "agent" }, { agentId: "agent-2", timestamp: "2026-09-28T00:00:00Z" })); + [...started, ...completed].forEach(validateAcpxRichEvent); + expect(started[0].eventType).toBe("delegation.started"); + expect(completed[0].eventType).toBe("delegation.completed"); + expect(started[0].itemId).toBe(completed[0].itemId); + expect(completed[1].payload).toMatchObject({ + provenance: { method: COPILOT_ACP_EVENT_METHOD, eventType: "subagent.completed", agentId: "agent-2", sessionId: "session-1", turnId: "turn-1", timestamp: "2026-09-28T00:00:00Z" }, + details: expect.arrayContaining([{ name: "totalTokens", value: "40" }, { name: "durationMs", value: "12.5" }, { name: "modelSelectionSource", value: "agent" }]), + }); + }); + + it("does not register provider-session paths or binary assets as task artifacts", async () => { + const adapter = createCopilotProfileExtensionAdapter(context); + const file = await adapter.notification(COPILOT_ACP_EVENT_METHOD, params("session.workspace_file_changed", { operation: "create", path: "reports/result.md" })); + file.forEach(validateAcpxRichEvent); + expect(file[0].payload).toMatchObject({ registered: false, reference: null }); + expect(file[1].payload.details).toEqual(expect.arrayContaining([{ name: "path", value: "reports/result.md" }, { name: "locationKind", value: "provider_session_workspace" }])); + expect(await adapter.notification(COPILOT_ACP_EVENT_METHOD, params("session.workspace_file_changed", { operation: "create", path: "../../secret" }))).toEqual([]); + const bytes = Buffer.from("image fixture"); + const asset = await adapter.notification(COPILOT_ACP_EVENT_METHOD, params("session.binary_asset", { assetId: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, type: "image", mimeType: "image/png", byteLength: bytes.length, data: bytes.toString("base64") })); + asset.forEach(validateAcpxRichEvent); + expect(asset[0].payload).toMatchObject({ mediaType: "image/png", reference: null, registered: false }); + expect(JSON.stringify(asset)).not.toContain(bytes.toString("base64")); + }); + + it("keeps supplemental non-dollar usage separate from authoritative terminal accounting", async () => { + const events = await createCopilotProfileExtensionAdapter(context).notification(COPILOT_ACP_EVENT_METHOD, params("assistant.usage", { model: "m", inputTokens: 10, outputTokens: 2, cost: 3, copilotUsage: { totalNanoAiu: 50 }, quotaSnapshots: { private: "secret" } })); + events.forEach(validateAcpxRichEvent); + expect(events).toHaveLength(1); + expect(events[0].eventType).toBe("provider.notice.recorded"); + expect(events[0].payload.details).toEqual(expect.arrayContaining([{ name: "modelMultiplier", value: "3" }, { name: "totalNanoAiu", value: "50" }, { name: "inputTokens", value: "10" }, { name: "accounting", value: "supplemental_provider_notice" }])); + expect(JSON.stringify(events)).not.toMatch(/costUsd|quotaSnapshots|secret/); + }); + + it("redacts credentials in provider strings while preserving numeric token counters", async () => { + const events = await createCopilotProfileExtensionAdapter(context).notification(COPILOT_ACP_EVENT_METHOD, params("subagent.failed", { agentName: "review", totalTokens: 42, error: "Authorization: Bearer secretsecretsecret", model: "ghp_abcdefghijklmnopqrstuvwxyz" })); + events.forEach(validateAcpxRichEvent); + expect(JSON.stringify(events)).not.toMatch(/secretsecretsecret|ghp_abcdefghijklmnopqrstuvwxyz/); + expect(events[1].payload.details).toContainEqual({ name: "totalTokens", value: "42" }); + }); + + it("reports native unsupported input honestly and ignores unrelated or cross-session notifications", async () => { + const adapter = createCopilotProfileExtensionAdapter(context); + await expect(adapter.request("user_input.requested", {})).rejects.toThrow("no qualified"); + const events = await adapter.notification(COPILOT_ACP_EVENT_METHOD, params("user_input.requested", { requestId: "q-1", question: "Do not fabricate an input form" })); + events.forEach(validateAcpxRichEvent); + expect(events[0].payload).toMatchObject({ severity: "warning", userActionable: false }); + expect(JSON.stringify(events)).not.toContain("Do not fabricate"); + expect(await adapter.notification("unrelated/event", {})).toEqual([]); + expect(await adapter.notification(COPILOT_ACP_EVENT_METHOD, { ...params("session.idle", {}), sessionId: "another" })).toEqual([]); + }); + + it("emits compaction completion only after provider-reported success and keeps settlement notices nonterminal", async () => { + const adapter = createCopilotProfileExtensionAdapter(context); + const success = await adapter.notification(COPILOT_ACP_EVENT_METHOD, params("session.compaction_complete", { success: true, preCompactionTokens: 100, postCompactionTokens: 40, checkpointNumber: 1 })); + success.forEach(validateAcpxRichEvent); + expect(success[0]).toMatchObject({ eventType: "context.compacted", payload: { preTokens: 100, postTokens: 40, sameSession: true } }); + const failure = await adapter.notification(COPILOT_ACP_EVENT_METHOD, params("session.compaction_complete", { success: false })); + expect(failure.map(event => event.eventType)).toEqual(["provider.notice.recorded"]); + const idle = await adapter.notification(COPILOT_ACP_EVENT_METHOD, params("session.idle", {})); + expect(idle.map(event => event.eventType)).toEqual(["provider.notice.recorded"]); + expect(idle[0].payload.details).toContainEqual({ name: "authoritativeTurnCompletion", value: "false" }); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.ts new file mode 100644 index 00000000000..f5840dd086f --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.ts @@ -0,0 +1,136 @@ +import { createHash } from "node:crypto"; +import type { CanonicalProviderEvent } from "../../provider-events.js"; +import { redactPaperclipSemanticValue } from "../../semantic-tools/redaction.js"; +import { COPILOT_ACP_EVENT_METHOD, normalizeCopilotSessionEvent, type CopilotSessionEvent } from "./copilot-events.js"; +import type { AcpxProfileExtensionAdapter, AcpxProfileExtensionContext } from "./profile-extensions.js"; + +/** Display projection only. Native notices cannot settle or charge a runner turn. */ +export function createCopilotProfileExtensionAdapter(context: AcpxProfileExtensionContext): AcpxProfileExtensionAdapter { + let sequence = 0; + return { + async request() { + throw new Error("Copilot 1.0.88 has no qualified inbound ACP extension request responder"); + }, + async notification(method, params) { + if (method !== COPILOT_ACP_EVENT_METHOD) return []; + const event = normalizeCopilotSessionEvent({ method, params }, context); + if (!event) return []; + const noticeId = identity(context, "notice", String(++sequence)); + const details = detailFields(event.data); + const notice: CanonicalProviderEvent = { + itemId: noticeId, + eventType: "provider.notice.recorded", + payload: { + schema: "paperclip.provider.notice.v1", noticeId, + severity: event.kind === "capability_gap" ? "warning" : "info", + category: `copilot.${event.sourceType}`, scope: "turn", + recoverable: true, userActionable: false, + summary: summary(event), + provenance: { + method, eventType: event.sourceType, sessionId: bounded(context.sessionId, 240), turnId: bounded(context.turnId, 240), + ...(event.agentId ? { agentId: bounded(event.agentId, 240) } : {}), + ...(event.timestamp ? { timestamp: bounded(event.timestamp, 80) } : {}), + }, + details, + }, + }; + const typed = typedProjection(event, context, noticeId); + return typed ? [typed, notice] : [notice]; + }, + }; +} + +function typedProjection(event: CopilotSessionEvent, context: AcpxProfileExtensionContext, fallbackId: string): CanonicalProviderEvent | null { + const data = event.data; + if (event.kind === "capability_gap") return null; + if (event.sourceType.startsWith("subagent.")) { + const correlation = event.agentId ?? string(data.toolCallId); + const itemId = correlation ? identity(context, "delegation", correlation) : fallbackId; + const status = data.cancelled === true ? "interrupted" + : event.sourceType === "subagent.completed" ? "completed" + : event.sourceType === "subagent.failed" ? "failed" : "running"; + return { + itemId, + eventType: status === "running" ? (event.sourceType === "subagent.started" ? "delegation.started" : "delegation.updated") : "delegation.completed", + payload: { + schema: "paperclip.delegation.v1", delegationId: itemId, action: "spawn", status, + children: [{ + childId: itemId, role: optional(data.agentDisplayName ?? data.agentName ?? data.agentType, 160), + model: optional(data.model, 240), status, + summary: optional(data.agentDescription ?? data.error, 4000), + activitySummary: summary(event), + }], + }, + }; + } + if (event.sourceType === "session.compaction_complete" && data.success === true) { + const itemId = identity(context, "compaction", String(data.checkpointNumber ?? fallbackId)); + return { itemId, eventType: "context.compacted", payload: { + schema: "paperclip.context.compacted.v1", compactionId: itemId, reason: "provider", + preTokens: nonnegativeInteger(data.preCompactionTokens), postTokens: nonnegativeInteger(data.postCompactionTokens), sameSession: true, + } }; + } + if (event.kind === "artifact_reference") { + const itemId = identity(context, "artifact", string(data.assetId) || string(data.path) || fallbackId); + return { itemId, eventType: "artifact.generated", payload: { + schema: "paperclip.artifact.generated.v1", artifactId: itemId, status: "completed", + // Provider-session paths are not task-workspace paths or registered URLs. + reference: null, mediaType: optional(data.mimeType, 160), registered: false, failure: null, + } }; + } + return null; +} + +function summary(event: CopilotSessionEvent): string { + const data = event.data; + if (event.kind === "capability_gap") return data.reason === "provider_omitted_event_data" + ? "Copilot omitted the payload of a native activity event." + : "Copilot reported native input without a qualified ACP response method."; + switch (event.sourceType) { + case "assistant.usage": return "Copilot reported supplemental model usage; these counters do not establish a dollar charge."; + case "session.usage_checkpoint": return "Copilot reported a cumulative AI-unit checkpoint; it is not a dollar charge."; + case "session.usage_info": return "Copilot updated context usage."; + case "session.workspace_file_changed": return `Copilot ${data.operation === "create" ? "created" : "updated"} a provider-session file; artifact resolution is pending.`; + case "session.binary_asset": return "Copilot reported a verified binary asset; artifact registration is pending."; + case "subagent.started": return "Copilot started a subagent."; + case "subagent.configured": return "Copilot updated subagent configuration."; + case "subagent.completed": return data.cancelled === true ? "Copilot subagent was interrupted." : "Copilot subagent completed."; + case "subagent.failed": return "Copilot subagent failed."; + case "session.plan_changed": return "Copilot changed its plan; this notification does not contain the plan document."; + case "session.compaction_start": return "Copilot started context compaction."; + case "session.compaction_complete": return data.success === true ? "Copilot completed context compaction." : "Copilot reported context compaction without a successful result."; + case "pending_messages.modified": return "Copilot changed its pending messages; the notification does not include queue contents."; + case "session.background_tasks_changed": return "Copilot background tasks changed; the notification does not include task contents."; + case "session.context_changed": return "Copilot reported context changes; the admitted workspace remains fixed."; + case "session.permissions_changed": return "Copilot reported permission state; Paperclip policy remains authoritative."; + case "session.mode_changed": return "Copilot reported a mode change."; + case "session.idle": return "Copilot reported idle activity; the ACP prompt response determines turn settlement."; + case "session.completion_receipt": return "Copilot reported a completion receipt; it does not independently settle the turn."; + default: return "Copilot reported native activity."; + } +} + +function detailFields(data: Record): Array<{ name: string; value: string }> { + const details: Array<{ name: string; value: string }> = []; + const visit = (value: unknown, name: string): void => { + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + for (const [key, entry] of Object.entries(value)) visit(entry, name ? `${name}.${key}` : key); + } else if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + // Redact values individually: numeric token counters are not credentials. + details.push({ name: bounded(name, 160), value: bounded(String(value), 4000) }); + } + }; + visit(data, ""); + if (details.length > 64) throw new Error("Copilot projected detail fields exceed their declared bound"); + return details; +} +function identity(context: AcpxProfileExtensionContext, family: string, source: string): string { + return `copilot-${family}-${createHash("sha256").update(JSON.stringify([context.sessionId, context.turnId, source])).digest("hex")}`; +} +function bounded(value: string, max: number): string { + const safe = String(redactPaperclipSemanticValue(value)); + return safe.length <= max ? safe : `${safe.slice(0, max - 12)} [truncated]`; +} +function optional(value: unknown, max: number): string | null { return typeof value === "string" && value ? bounded(value, max) : null; } +function string(value: unknown): string { return typeof value === "string" ? value : ""; } +function nonnegativeInteger(value: unknown): number | null { return Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : null; }