From 969944b38e082cc363fd9ac58a2b8fe722bcd9f1 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 28 Sep 2026 11:31:15 -0500 Subject: [PATCH] Bind Copilot launches to pinned build-owned native assets Co-Authored-By: Paperclip --- .../drivers/acpx/copilot-extension-adapter.ts | 4 +- .../drivers/acpx/copilot-installation.test.ts | 46 ++++++++++++++++ .../src/drivers/acpx/copilot-installation.ts | 54 +++++++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/copilot-installation.ts 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 f5840dd086f..215d1030259 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.ts @@ -3,11 +3,13 @@ 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 { +export function createCopilotProfileExtensionAdapter(context: AcpxProfileExtensionContext): AcpxProfileExtensionAdapter & { classifyError: typeof classifyCopilotFailure } { 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-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts new file mode 100644 index 00000000000..7eed92a785a --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -0,0 +1,46 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it, vi } from "vitest"; +import { verifyNativeAcpxInstallation } from "./installation-integrity.js"; +import { COPILOT_CLOSURE_SHA256, verifyCopilotInstallation } from "./copilot-installation.js"; +import { COPILOT_LAUNCH_ARGUMENTS } from "./copilot-profile.js"; +import { QUALIFIED_ACPX_PROFILES } from "./qualified-profiles.js"; + +vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: vi.fn() })); + +describe("Copilot build-owned installation", () => { + it("pins the materializer's complete executable closure on every target platform", () => { + const script = readFileSync(new URL("../../../scripts/materialize-copilot-binary.mjs", import.meta.url), "utf8"); + const records = [...script.matchAll(/"(darwin-arm64|darwin-x64|linux-x64)": Object\.freeze\(\{ packageName: "[^"]+", executableDigest: "([a-f0-9]+)", size: (\d+)/g)]; + expect(records).toHaveLength(3); + for (const [, platform, sha256, size] of records) { + const digest = createHash("sha256").update(JSON.stringify([{ path: "copilot", sha256, size: Number(size), executable: true }])).digest("hex"); + expect(COPILOT_CLOSURE_SHA256[platform as keyof typeof COPILOT_CLOSURE_SHA256]).toBe(digest); + } + }); + it("ignores ambient executable selection and supplies only pinned native launch inputs", async () => { + const openCommand = vi.fn(); + vi.mocked(verifyNativeAcpxInstallation).mockResolvedValueOnce({ commandDigest: "sha256:closure", agentServerPackageJsonPath: "/verified/closure.json", agentRuntimePackageJsonPath: null, openCommand }); + const before = process.env.COPILOT_PATH; + process.env.COPILOT_PATH = "/untrusted/copilot"; + try { + const result = await verifyCopilotInstallation(QUALIFIED_ACPX_PROFILES.copilot); + const root = fileURLToPath(new URL(`../../../provider-assets/copilot/${process.platform}-${process.arch}`, import.meta.url)); + expect(verifyNativeAcpxInstallation).toHaveBeenCalledWith({ + distributionRoot: root, manifestPath: `${root}/.paperclip-copilot-closure.json`, + expectedClosureSha256: COPILOT_CLOSURE_SHA256[`${process.platform}-${process.arch}` as keyof typeof COPILOT_CLOSURE_SHA256], + executable: "copilot", fixedArguments: COPILOT_LAUNCH_ARGUMENTS, isolatedCacheEnvironmentName: "COPILOT_PKG_CACHE_HOME", + }); + expect(result.commandDigest).toBe(QUALIFIED_ACPX_PROFILES.copilot.commandDigest); + expect(result.openCommand).toBe(openCommand); + } finally { if (before === undefined) delete process.env.COPILOT_PATH; else process.env.COPILOT_PATH = before; } + }); + it("rejects changed profile identities before native file access", async () => { + vi.mocked(verifyNativeAcpxInstallation).mockClear(); + for (const override of [{ agentServerVersion: "latest" }, { commandDigest: "sha256:untrusted" }, { agent: "cursor" }, { agentProfileVersion: 1 }]) { + await expect(verifyCopilotInstallation({ ...QUALIFIED_ACPX_PROFILES.copilot, ...override } as never)).rejects.toThrow("exact pinned"); + } + expect(verifyNativeAcpxInstallation).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.ts new file mode 100644 index 00000000000..436b0ac31aa --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.ts @@ -0,0 +1,54 @@ +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { verifyNativeAcpxInstallation, type VerifiedAcpxInstallation } from "./installation-integrity.js"; +import { COPILOT_LAUNCH_ARGUMENTS, COPILOT_VERSION } from "./copilot-profile.js"; +import { QUALIFIED_ACPX_PROFILES, type QualifiedAcpxProfile } from "./qualified-profiles.js"; + +export const COPILOT_CLOSURE_SHA256 = Object.freeze({ + "darwin-arm64": "fb3b367a45cd76122fe931521fa2a18adf234ba944fc302db9e10e005e57037e", + "darwin-x64": "05f3497b336b3efdec347beb2e3b80b02cfa95f811fafddc25d0b029ab95d711", + "linux-x64": "1a675c5b54ae4d94f08718a318451e0499708ded388b4cfd98acec6b4311ccbd", +}); + +/** Admission primitive only. Pending candidates remain gated by qualification. */ +export async function verifyCopilotInstallation(profile: QualifiedAcpxProfile): Promise { + const expected = QUALIFIED_ACPX_PROFILES.copilot; + if (profile.agent !== "copilot" || profile.agentProfileVersion !== 2 + || profile.acpxVersion !== expected.acpxVersion || profile.agentServerPackage !== "@github/copilot" + || profile.agentServerVersion !== COPILOT_VERSION || profile.commandDigest !== expected.commandDigest + || profile.agentRuntimePackage !== null || profile.agentRuntimeVersion !== null) { + throw new Error("Copilot installation requires the exact pinned candidate profile"); + } + const platform = `${process.platform}-${process.arch}`; + if (!Object.prototype.hasOwnProperty.call(COPILOT_CLOSURE_SHA256, platform)) { + throw new Error(`Copilot native distribution is not pinned for ${platform}`); + } + const distributionRoot = join(ownedPackageRoot(), "provider-assets", "copilot", platform); + const native = await verifyNativeAcpxInstallation({ + distributionRoot, + manifestPath: join(distributionRoot, ".paperclip-copilot-closure.json"), + expectedClosureSha256: COPILOT_CLOSURE_SHA256[platform as keyof typeof COPILOT_CLOSURE_SHA256], + executable: "copilot", fixedArguments: COPILOT_LAUNCH_ARGUMENTS, + isolatedCacheEnvironmentName: "COPILOT_PKG_CACHE_HOME", + }); + // The host identity binds the versioned profile. Native admission independently + // binds the complete platform closure before creating each single-use lease. + return Object.freeze({ ...native, commandDigest: expected.commandDigest }); +} + +function ownedPackageRoot(): string { + const modulePath = fileURLToPath(import.meta.url); + const directory = dirname(modulePath); + if (basename(directory) === "acpx" && basename(dirname(directory)) === "drivers" + && ["src", "dist"].includes(basename(dirname(dirname(directory)))) + && /^copilot-installation\.(?:ts|js)$/.test(basename(modulePath))) { + return resolve(directory, "../../.."); + } + // The verified CommonJS entrypoint replaces import.meta.url with its own URL; + // the bundled ESM entrypoint has the same runtime layout. + if (basename(directory) === "cli" && basename(dirname(directory)) === "dist" + && /^acpx-runtime-sidecar\.(?:cjs|js)$/.test(basename(modulePath))) { + return resolve(directory, "../.."); + } + throw new Error("Copilot assets require the runner's build-owned module layout"); +}