From 4fc5dbd4ec6fce6cb036276df2ee6435aa482c1f Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 28 Sep 2026 11:50:52 -0500 Subject: [PATCH] Connect Copilot candidate installation, events, diagnostics and pack registries Co-Authored-By: Paperclip --- .../runner-copilot-capabilities.md | 15 +++- .../scripts/candidate-provider-pack.mjs | 4 + .../scripts/candidate-provider-pack.test.mjs | 13 ++++ .../drivers/acpx/copilot-extension-adapter.ts | 4 +- .../src/drivers/acpx/copilot-profile.ts | 4 +- .../src/drivers/acpx/copilot-registry.test.ts | 73 +++++++++++++++++++ .../src/drivers/acpx/profile-extensions.ts | 11 ++- .../src/drivers/acpx/profile-installation.ts | 12 ++- 8 files changed, 122 insertions(+), 14 deletions(-) create mode 100644 packages/paperclip-runner/src/drivers/acpx/copilot-registry.test.ts diff --git a/doc/architecture/runner-copilot-capabilities.md b/doc/architecture/runner-copilot-capabilities.md index a39a62b7ce..f6ec66782a 100644 --- a/doc/architecture/runner-copilot-capabilities.md +++ b/doc/architecture/runner-copilot-capabilities.md @@ -174,7 +174,8 @@ Reasons and priorities are explicit: | Native external-tool/sampling/limits callbacks | No qualified ACP responder. P0 prove no unresolved request on admitted model/tools; otherwise keep release unqualified. | | Native capability/model/session lifecycle/config notices | Initial handshake and normalized session/config are admission authority. P1 detect capability/model drift and fail closed rather than treat a notice as authorization. | -Subagent subscribed fields are preserved in full. Empty native +Subagent subscribed fields are preserved within the declared text bounds; +truncated display strings carry an explicit truncation marker. Empty native `pending_messages.modified` and `session.background_tasks_changed` events have no queue/task list to preserve; their projection explicitly says refresh unavailable. Native context repository/git-root strings, completion receipt finalTool, @@ -196,7 +197,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 packages/paperclip-runner/scripts/build-copilot-distribution.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 +pnpm --filter @paperclipai/paperclip-runner exec vitest run src/drivers/acpx/copilot-extension-adapter.test.ts src/drivers/acpx/copilot-registry.test.ts ``` Retained real-binary evidence: @@ -239,3 +240,13 @@ builder, returning the profile digest above and closure The temporary output was removed after verification. This packaging proof used no model credentials, executed no provider turn, and incurred $0 model spend; it does not qualify either local product behavior or Daytona execution. + +The Copilot branch connects all three closed registries: profile installation +selects the pinned native verifier, profile extensions advertise only the 22 +selected native event types and create the Copilot adapter, and candidate packs +select the verified archive builder. Registry conformance checks the complete +subagent field projection through the shared turn binder, attribution, canonical +schema validation, meaningful display details, and stale/cross-session rejection. +Admission error classification distinguishes missing authentication, account or +organization denial, and unavailable explicit models using fixed safe messages; +unrelated runner integrity errors keep their original classification. diff --git a/packages/paperclip-runner/scripts/candidate-provider-pack.mjs b/packages/paperclip-runner/scripts/candidate-provider-pack.mjs index 083d8fbb3a..f328263212 100644 --- a/packages/paperclip-runner/scripts/candidate-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/candidate-provider-pack.mjs @@ -18,5 +18,9 @@ export function parseProviderPackArguments(args) { /** Closed source-owned builder registry; provider branches add their exact pins. */ export async function materializeCandidateProviderPack({ provider, outputRoot }) { if (!CANDIDATES.has(provider)) throw new Error("Unknown candidate provider"); + if (provider === "copilot") { + const { buildPinnedCopilotDistribution } = await import("./build-copilot-distribution.mjs"); + return buildPinnedCopilotDistribution({ outputRoot }); + } throw new Error(`The ${provider} candidate distribution builder is not included in this source revision`); } diff --git a/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs b/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs index c95cccff97..f318dda0d7 100644 --- a/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs +++ b/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs @@ -12,3 +12,16 @@ test("candidate builder cannot admit unknown providers, options or duplicate ass } await assert.rejects(materializeCandidateProviderPack({ provider: "arbitrary", outputRoot: "/tmp/unused" }), /Unknown candidate/); }); + +test("Copilot pack selection reaches only the pinned native archive builder", async t => { + const requests = []; + t.mock.method(globalThis, "fetch", async (url, options) => { + requests.push({ url, redirect: options.redirect, credentials: options.credentials }); + return new Response("corrupt archive"); + }); + await assert.rejects(materializeCandidateProviderPack({ provider: "copilot", outputRoot: "/unused-copilot-build" }), /integrity mismatch/); + assert.equal(requests.length, 1); + assert.match(requests[0].url, /^https:\/\/registry\.npmjs\.org\/@github\/copilot-(darwin-(arm64|x64)|linux-x64)\/-\/copilot-.*-1\.0\.88\.tgz$/); + assert.deepEqual({ redirect: requests[0].redirect, credentials: requests[0].credentials }, { redirect: "error", credentials: "omit" }); + await assert.rejects(materializeCandidateProviderPack({ provider: "pi", outputRoot: "/unused" }), /not included/); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.ts index 215d103025..f5840dd086 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.ts @@ -3,13 +3,11 @@ 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"; -import { classifyCopilotFailure } from "./copilot-profile.js"; /** Display projection only. Native notices cannot settle or charge a runner turn. */ -export function createCopilotProfileExtensionAdapter(context: AcpxProfileExtensionContext): AcpxProfileExtensionAdapter & { classifyError: typeof classifyCopilotFailure } { +export function createCopilotProfileExtensionAdapter(context: AcpxProfileExtensionContext): AcpxProfileExtensionAdapter { let sequence = 0; return { - classifyError: classifyCopilotFailure, async request() { throw new Error("Copilot 1.0.88 has no qualified inbound ACP extension request responder"); }, diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-profile.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-profile.ts index 970acb3d0f..ed67c5e700 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-profile.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-profile.ts @@ -65,13 +65,13 @@ export function classifyCopilotFailure(error: unknown): { code: CopilotFailureCo : typeof error === "string" ? error : isRecord(error) && typeof error.message === "string" ? error.message : ""; const message = source.slice(0, 8192).toLowerCase(); - if (/authentication required|unauthorized|invalid (?:github )?token|not (?:logged|signed) in/.test(message)) { + if (/authentication required|unauthorized|invalid (?:github )?token|not (?:logged|signed) in|copilot_github_token.*(?:missing|required|not configured)|(?:missing|no) (?:copilot |github )?(?:credential|token|authentication)/.test(message)) { return { code: "COPILOT_AUTH_REQUIRED", message: "Bind a valid COPILOT_GITHUB_TOKEN credential for this runner." }; } if (/entitlement|subscription|organization policy|organisation policy|policy.*(?:denied|disabled)|access denied|forbidden|not entitled/.test(message)) { return { code: "COPILOT_ENTITLEMENT_DENIED", message: "Copilot access is denied by the account entitlement or organization policy." }; } - if (/(?:model.*(?:not found|unavailable|not available|unsupported|invalid)|unknown model|invalid model)/.test(message)) { + if (/(?:model.*(?:not found|unavailable|not available|not supported|unsupported|invalid)|unknown model|invalid model)/.test(message)) { return { code: "COPILOT_MODEL_UNAVAILABLE", message: "The explicitly selected Copilot model is unavailable for this account." }; } return { code: "COPILOT_REQUEST_FAILED", message: "The Copilot ACP request failed." }; diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-registry.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-registry.test.ts new file mode 100644 index 0000000000..32c6712d79 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-registry.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from "vitest"; +import type { CanonicalProviderEvent } from "../../provider-events.js"; +import { COPILOT_ACP_CLIENT_CAPABILITIES, COPILOT_ACP_EVENT_METHOD } from "./copilot-events.js"; +import { acpxProfileClientCapabilities, bindAcpxExtensionTurn, createAcpxProfileExtensionAdapter } from "./profile-extensions.js"; +import { classifyAcpxProfileError, verifyAcpxProfileInstallation } from "./profile-installation.js"; +import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js"; +import { verifyCopilotInstallation } from "./copilot-installation.js"; + +vi.mock("./copilot-installation.js", () => ({ verifyCopilotInstallation: vi.fn(async () => ({ commandDigest: "verified-by-native-factory" })) })); + +const context = { workspacePath: "/workspace", sessionId: "backend-1", turnId: "turn-1" }; +describe("Copilot provider registry conformance", () => { + it("selects only the Copilot native installer and preserves the exact caller-selected profile", async () => { + const profile = resolveQualifiedAcpxProfile("copilot", "explicit-exact-model"); + expect(await verifyAcpxProfileInstallation(profile)).toMatchObject({ commandDigest: "verified-by-native-factory" }); + expect(verifyCopilotInstallation).toHaveBeenCalledExactlyOnceWith(profile); + await expect(verifyAcpxProfileInstallation(resolveQualifiedAcpxProfile("cursor", "exact-model"))).rejects.toThrow(/not installed/); + }); + + it("negotiates an isolated native event subscription and preserves every safe subagent field through the shared turn binder", async () => { + const capabilities = acpxProfileClientCapabilities("copilot"); + expect(capabilities).toEqual(COPILOT_ACP_CLIENT_CAPABILITIES); + (capabilities._meta as Record)["github.com/copilot"] = {}; + expect(acpxProfileClientCapabilities("copilot")).toEqual(COPILOT_ACP_CLIENT_CAPABILITIES); + expect(acpxProfileClientCapabilities("codex")).toEqual({}); + const emitted: CanonicalProviderEvent[] = []; + const data = { + toolCallId: "tool-1", agentName: "review", agentDisplayName: "Reviewer", agentDescription: "Review the change", + model: "exact-model", parentId: "parent-1", agentType: "task", executionMode: "background", reasoningEffort: "high", + contextTier: "large", firstDispatchedModel: "model-initial", configuredModelPreference: "model-preferred", + explicitModelOverride: "model-explicit", modelOverrideReason: "task request", modelSelectionSource: "agent", + taskModelSource: "task", factoryRunId: "factory-1", totalToolCalls: 3, totalTokens: 40, durationMs: 12.5, + cancelled: false, resumable: true, multiTurn: true, explicitModelMatchesPreference: false, configuredModelMatchesActual: false, + }; + const binder = bindAcpxExtensionTurn({ adapter: createAcpxProfileExtensionAdapter("copilot", context), + active: () => true, sessionId: context.sessionId, waitForInput: async () => { throw new Error("No input allowed"); }, emit: event => emitted.push(event) }); + binder.onExtensionNotification(COPILOT_ACP_EVENT_METHOD, { sessionId: context.sessionId, type: "subagent.completed", data, + agentId: "agent-2", timestamp: "2026-09-28T00:00:00Z" }); + await binder.drain(); + expect(emitted.map(event => event.eventType)).toEqual(["delegation.completed", "provider.notice.recorded"]); + expect(emitted[0]!.payload).toMatchObject({ schema: "paperclip.delegation.v1", action: "spawn", status: "completed", + children: [{ role: "Reviewer", model: "exact-model", status: "completed", summary: "Review the change" }] }); + expect(emitted[1]!.payload).toMatchObject({ summary: "Copilot subagent completed.", provenance: { + method: COPILOT_ACP_EVENT_METHOD, eventType: "subagent.completed", agentId: "agent-2", + sessionId: "backend-1", turnId: "turn-1", timestamp: "2026-09-28T00:00:00Z", + } }); + expect(emitted[1]!.payload.details).toEqual(Object.entries(data).map(([name, value]) => ({ name, value: String(value) }))); + }); + + it.each(["wrong_session", "stale_turn"])("rejects %s events at the shared boundary before presentation", async scenario => { + const emitted: CanonicalProviderEvent[] = []; + const binder = bindAcpxExtensionTurn({ adapter: createAcpxProfileExtensionAdapter("copilot", context), + active: () => scenario !== "stale_turn", sessionId: context.sessionId, waitForInput: async () => ({}), emit: event => emitted.push(event) }); + binder.onExtensionNotification(COPILOT_ACP_EVENT_METHOD, { sessionId: scenario === "wrong_session" ? "agent-1" : "backend-1", type: "session.idle", data: {} }); + await expect(binder.drain()).rejects.toThrow(); + expect(emitted).toEqual([]); + }); + + it("classifies admission failures without copying credentials or masking unrelated runner errors", () => { + for (const [message, code] of [ + ["COPILOT_GITHUB_TOKEN is required", "COPILOT_AUTH_REQUIRED"], + ["Unauthorized token ghp_abcdefghijklmnopqrstuvwxyz", "COPILOT_AUTH_REQUIRED"], + ["Organization policy denied with Bearer secretsecretsecret", "COPILOT_ENTITLEMENT_DENIED"], + ["Model custom-model is not supported", "COPILOT_MODEL_UNAVAILABLE"], + ]) { + const error = classifyAcpxProfileError("copilot", new Error(message)); + expect(error).toMatchObject({ code, retryable: false }); + expect(String(error)).not.toMatch(/ghp_|secretsecretsecret|custom-model/); + } + expect(classifyAcpxProfileError("codex", new Error("Unauthorized"))).toBeNull(); + expect(classifyAcpxProfileError("copilot", new Error("native distribution digest mismatch"))).toBeNull(); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-extensions.ts b/packages/paperclip-runner/src/drivers/acpx/profile-extensions.ts index 070088aab8..4fa5b8f41a 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-extensions.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-extensions.ts @@ -1,3 +1,5 @@ +import { createCopilotProfileExtensionAdapter } from "./copilot-extension-adapter.js"; +import { COPILOT_ACP_CLIENT_CAPABILITIES } from "./copilot-events.js"; import type { HarnessRuntimeRequestResolution } from "../../contracts/harness-driver.js"; import { parsePaperclipQuestionSet, type PaperclipQuestionSet } from "../../contracts/question-set.js"; import { isCanonicalProviderEventType, type CanonicalProviderEvent } from "../../provider-events.js"; @@ -33,13 +35,14 @@ export interface AcpxProfileExtensionContext { /** Provider branches install their closed, pinned adapters here after qualification research. */ export function createAcpxProfileExtensionAdapter( - _agent: QualifiedAcpxAgent, - _context: AcpxProfileExtensionContext, + agent: QualifiedAcpxAgent, + context: AcpxProfileExtensionContext, ): AcpxProfileExtensionAdapter | null { + if (agent === "copilot") return createCopilotProfileExtensionAdapter(context); return null; } -export function acpxProfileClientCapabilities(_agent: QualifiedAcpxAgent): Record { - return {}; +export function acpxProfileClientCapabilities(agent: QualifiedAcpxAgent): Record { + return agent === "copilot" ? structuredClone(COPILOT_ACP_CLIENT_CAPABILITIES) : {}; } /** Reject an oversized approval document; never silently approve a truncated revision. */ diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts b/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts index a78aa47c6c..779a4e8ae0 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts @@ -1,8 +1,11 @@ +import { classifyCopilotFailure } from "./copilot-profile.js"; +import { verifyCopilotInstallation } from "./copilot-installation.js"; import type { QualifiedAcpxAgent, QualifiedAcpxProfile } from "./qualified-profiles.js"; import { verifyQualifiedAcpxInstallation, type VerifiedAcpxInstallation } from "./installation-integrity.js"; /** Closed build-owned registry. Provider branches add their pinned installations here. */ export async function verifyAcpxProfileInstallation(profile: QualifiedAcpxProfile): Promise { + if (profile.agent === "copilot") return verifyCopilotInstallation(profile); if (profile.agent !== "claude" && profile.agent !== "codex" && profile.agent !== "grok") { throw new Error(`ACPX ${profile.agent} verified candidate distribution is not installed in this build`); } @@ -15,7 +18,10 @@ export async function assertAcpxProfileWorkspace(_agent: QualifiedAcpxAgent, _wo /** Candidate branches validate only explicitly bound, sanitized launch credentials. */ export function assertAcpxProfileEnvironment(_agent: QualifiedAcpxAgent, _environment: Readonly): void {} -/** Optional provider-specific classification; never changes whether admission succeeded. */ -export function classifyAcpxProfileError(_agent: QualifiedAcpxAgent, _error: unknown): Error | null { - return null; +/** Provider admission diagnostics expose no raw provider strings or credentials. */ +export function classifyAcpxProfileError(agent: QualifiedAcpxAgent, error: unknown): Error | null { + if (agent !== "copilot") return null; + const failure = classifyCopilotFailure(error); + if (failure.code === "COPILOT_REQUEST_FAILED") return null; + return Object.assign(new Error(failure.message), { code: failure.code, retryable: false }); }