Project Copilot native events into attributed runner activity

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-28 15:17:09 -05:00
1 parent cd4db5660a
commit fe3b7e6fb8
3 files changed
+230

No files matched your search

@@ -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:
@@ -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" });
});
});
@@ -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<string, unknown>): 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; }