From 1bd7e87482f52c041f7e0bb144e79eabd5784445 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 9 Oct 2026 11:40:15 -0500 Subject: [PATCH 1/3] Repair Copilot packaging and isolate current instructions in profile 35 Keep Pi source identity intact, expose the metadata probe, synchronize current admission contracts, and restore exact instruction ownership assertions. Co-Authored-By: Paperclip --- packages/paperclip-runner/acpx-profiles.json | 4 +- packages/paperclip-runner/package.json | 3 +- .../runner-core/src/acpx_provider_backend.rs | 3 +- .../src/generated_acpx_profiles.rs | 2 +- .../tests/native_provider_backend.rs | 2 +- .../scripts/build-provider-pack.mjs | 1 + .../scripts/copilot-profile-identity.mjs | 4 +- .../scripts/generate-acpx-profiles.mjs | 2 +- .../drivers/acpx/copilot-installation.test.ts | 66 +++++---------- .../src/drivers/acpx/copilot-profile.test.ts | 12 ++- .../src/drivers/acpx/copilot-profile.ts | 9 +++ .../drivers/acpx/copilot-runtime-host.test.ts | 10 ++- .../src/drivers/acpx/generated-profiles.ts | 4 +- .../src/drivers/acpx/runtime-host.ts | 6 +- .../src/drivers/acpx/runtime-sandbox.test.ts | 14 ++-- .../src/drivers/acpx/runtime-sandbox.ts | 10 +-- .../copilot-profile-v35-identity.json | 81 +++++++++++++++++++ .../native-runtime/runtime-mode.test.ts | 11 ++- 18 files changed, 166 insertions(+), 78 deletions(-) create mode 100644 packages/paperclip-runner/test/fixtures/copilot-profile-v35-identity.json diff --git a/packages/paperclip-runner/acpx-profiles.json b/packages/paperclip-runner/acpx-profiles.json index 3a79a74032..c7dd5a7f32 100644 --- a/packages/paperclip-runner/acpx-profiles.json +++ b/packages/paperclip-runner/acpx-profiles.json @@ -32,12 +32,12 @@ "requiresProviderPolicy": true }, "copilot": { - "agentProfileVersion": 34, + "agentProfileVersion": 35, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:6dde98a9dbadf22f17ca28f133dbe2a65fa199827741190f569704e44bdc20ed", + "commandDigest": "sha256:6ecb42102c9440e5c8fe5b462b894f9457f255b29f8c584d8b713057cffe4327", "requiresProviderPolicy": true }, "claude": { diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index f1ec09e14e..e040f8df92 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -11,7 +11,8 @@ "paperclip-runner-eval-session": "./dist/cli/eval-session.js", "paperclip-runner-codex-proxy": "./dist/cli/codex-app-server-unix-proxy.js", "paperclip-runner-opencode-proxy": "./dist/cli/opencode-app-server-proxy.js", - "paperclip-runner-acpx-sidecar": "./dist/cli/acpx-runtime-sidecar.js" + "paperclip-runner-acpx-sidecar": "./dist/cli/acpx-runtime-sidecar.js", + "paperclip-runner-copilot-metadata-probe": "./dist/cli/copilot-metadata-probe.js" }, "exports": { ".": { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index 8f01f705a4..79324882e5 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -4083,7 +4083,7 @@ mod tests { descriptor_value["agentRuntimePackage"] = Value::Null; descriptor_value["agentRuntimeVersion"] = Value::Null; descriptor_value["commandDigest"] = serde_json::from_str::(include_str!( - "../../../../test/fixtures/copilot-profile-v34-identity.json" + "../../../../test/fixtures/copilot-profile-v35-identity.json" )) .unwrap()["commandDigest"] .clone(); @@ -4127,6 +4127,7 @@ mod tests { effective_model: original_descriptor.model.clone(), permission_mode: Some(original_descriptor.permission_mode), mode: original_descriptor.mode.clone(), + pi_thinking_level: None, provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], }; let operations = Vec::new(); diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs index dd2ed8962b..a7988b6f44 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs @@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option { agent_runtime_package: None, agent_runtime_version: None, command_digest: - "sha256:6dde98a9dbadf22f17ca28f133dbe2a65fa199827741190f569704e44bdc20ed", + "sha256:6ecb42102c9440e5c8fe5b462b894f9457f255b29f8c584d8b713057cffe4327", requires_provider_policy: true, }, "claude" => AcpxReleaseProfile { diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs index 2fb94b988b..0763718e91 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs @@ -1030,7 +1030,7 @@ fn provider_death_expires_input_and_commits_failure_without_querying_the_dead_pr let mut config = acpx_config(&directory, mode); let mut payload = prepare_payload_with_mode(&directory, "codex", mode); let identity: Value = serde_json::from_str(include_str!( - "../../../../test/fixtures/copilot-profile-v34-identity.json" + "../../../../test/fixtures/copilot-profile-v35-identity.json" )) .unwrap(); let digest = identity["commandDigest"].as_str().unwrap(); diff --git a/packages/paperclip-runner/scripts/build-provider-pack.mjs b/packages/paperclip-runner/scripts/build-provider-pack.mjs index bdf0886af4..9ed43f2cd0 100644 --- a/packages/paperclip-runner/scripts/build-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/build-provider-pack.mjs @@ -206,6 +206,7 @@ try { writePortableExecutableShim(temporaryRoot, "opencode", "opencode-ai/bin/opencode.exe"); writePortableCopilotShims(temporaryRoot); writePortableNodeShim("acpx", "acpx/dist/cli.js"); + writePortableNodeShim("paperclip-runner-copilot-metadata-probe", "../dist/cli/copilot-metadata-probe.js"); writePortableNodeShim( "claude-agent-acp", "@agentclientprotocol/claude-agent-acp/dist/index.js", diff --git a/packages/paperclip-runner/scripts/copilot-profile-identity.mjs b/packages/paperclip-runner/scripts/copilot-profile-identity.mjs index 3b932b141b..cb6ddf39bf 100644 --- a/packages/paperclip-runner/scripts/copilot-profile-identity.mjs +++ b/packages/paperclip-runner/scripts/copilot-profile-identity.mjs @@ -7,9 +7,9 @@ import { fileURLToPath } from "node:url"; import { resolve, dirname } from "node:path"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const fixture = resolve(root, "test/fixtures/copilot-profile-v34-identity.json"); +const fixture = resolve(root, "test/fixtures/copilot-profile-v35-identity.json"); const historical = JSON.parse(readFileSync(resolve(root, "test/fixtures/copilot-profile-v12-identity.json"), "utf8")); -const declaration = { ...historical.declaration, agentProfileVersion: 34 }; +const declaration = { ...historical.declaration, agentProfileVersion: 35 }; delete declaration.permissionIdentitySourceSha256; const sources = { displayEventSourceSha256: "src/drivers/acpx/copilot-events.ts", diff --git a/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs b/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs index 3832226117..358a196814 100644 --- a/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs +++ b/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs @@ -39,7 +39,7 @@ const { commandDigest, ...attestation } = contract; assert.equal(commandDigest, `sha256:${createHash("sha256").update(canonicalJson(attestation)).digest("hex")}`); assert.equal(contract.acpxPatchSha256, createHash("sha256") .update(await readFile(new URL("../../patches/acpx@0.13.1.patch", root))).digest("hex")); -for (const [agent, path] of [["pi", "test-fixtures/pi-acp/profile-v22-identity.json"], ["copilot", "test/fixtures/copilot-profile-v34-identity.json"]]) { +for (const [agent, path] of [["pi", "test-fixtures/pi-acp/profile-v22-identity.json"], ["copilot", "test/fixtures/copilot-profile-v35-identity.json"]]) { const identity = await readJson(path); assert.equal(manifest.profiles[agent].agentProfileVersion, identity.declaration.agentProfileVersion); assert.equal(manifest.profiles[agent].commandDigest, identity.commandDigest); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts index 999efa6eed..87e573f04a 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -13,10 +13,15 @@ import { QUALIFIED_ACPX_PROFILES } from "./qualified-profiles.js"; const PERMISSION_POLICY_SOURCES = [ ["copilot-permission-context.ts", "permissionContextSourceSha256"], ["acp-permission-adapter.ts", "permissionAdapterSourceSha256"], + ["cursor-plan-tool-identity.ts", "permissionToolIdentitySourceSha256"], ["safe-locations.ts", "permissionLocationsSourceSha256"], ["../../semantic-tools/redaction.ts", "permissionRedactionSourceSha256"], ["generated-sidecar-contract.ts", "permissionClassifierSourceSha256"], - ["cursor-plan-tool-identity.ts", "permissionIdentitySourceSha256"], +] as const; + +const COPILOT_DISPLAY_SOURCES = [ + ["copilot-events.ts", "displayEventSourceSha256"], + ["copilot-extension-adapter.ts", "displayProjectionSourceSha256"], ] as const; const SEMANTIC_RECEIPT_SOURCES = [ @@ -29,6 +34,11 @@ const SEMANTIC_RECEIPT_SOURCES = [ ["../../protocol/replay-contract.ts", "semanticValidationSourceSha256"], ["../../protocol/result-normalization.ts", "semanticNormalizationSourceSha256"], ["../../contracts/completion-result.ts", "semanticCompletionContractSourceSha256"], + ["../../contracts/native-execution.ts", "nativeExecutionSourceSha256"], + ["installation-integrity.ts", "guardedInstallationSourceSha256"], + ["../../live/runnerd-codex-transport.ts", "runtimeRequestBridgeSourceSha256"], + ["../codex/codex-session-notifications.ts", "runtimeRequestFacadeSourceSha256"], + ["../codex/codex-thread-normalization.ts", "runtimeRequestNotificationSourceSha256"], ["../../protocol/generated/standalone-validators.ts", "semanticValidatorsSourceSha256"], ["../../protocol/generated/schema-bundle.ts", "semanticSchemaBundleSourceSha256"], ] as const; @@ -59,36 +69,8 @@ async function permissionPolicyClosure(extraClassifierImport = false): Promise ({ verifyNativeAcpxInstallation: vi.fn() })); describe("Copilot build-owned installation", () => { - it("preserves v12 history and changes only the version and four shared source bindings", () => { - const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v12-identity.json", import.meta.url), "utf8")); - const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v13-identity.json", import.meta.url), "utf8")); - const changed = Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort(); - expect(changed).toEqual(["agentProfileVersion", "semanticDirectDriverSourceSha256", "semanticSchemaBundleSourceSha256", "semanticSidecarProtocolSourceSha256", "semanticSidecarSourceSha256"]); - expect(Object.keys(current.declaration).sort()).toEqual(Object.keys(prior.declaration).sort()); - expect(prior.declaration.agentProfileVersion).toBe(12); - expect(current.declaration.agentProfileVersion).toBe(13); - const sortedPrior = Object.fromEntries(Object.entries(prior.declaration).sort(([a], [b]) => a.localeCompare(b))); - expect(`sha256:${createHash("sha256").update(JSON.stringify(sortedPrior)).digest("hex")}`).toBe(prior.commandDigest); - expect(current.commandDigest).not.toBe(prior.commandDigest); - expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending"); - }); - - it("preserves v13 history while binding the shared live request snapshot", () => { - const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v13-identity.json", import.meta.url), "utf8")); - const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v14-identity.json", import.meta.url), "utf8")); - expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual(["agentProfileVersion", "semanticSidecarSourceSha256"]); - expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending"); - }); - - it("preserves v14 history while binding the corrected outbound ACPX delivery", () => { - const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v14-identity.json", import.meta.url), "utf8")); - const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v15-identity.json", import.meta.url), "utf8")); - expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual(["acpxPatchSha256", "agentProfileVersion"]); - expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending"); - }); - - it("admits the current v17 declaration and binds its source policy, receipts and patch hashes", () => { - const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v17-identity.json", import.meta.url), "utf8")); + it("admits the current declaration and binds its source policy, receipts and patch hashes", () => { + const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v35-identity.json", import.meta.url), "utf8")); expect(identity.declaration.systemInstructionDelivery).toBe(COPILOT_SYSTEM_INSTRUCTION_DELIVERY); expect(identity.declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1"); expect(identity.declaration.permissionContextContract).toBe(COPILOT_PERMISSION_CONTEXT_CONTRACT); @@ -104,10 +86,12 @@ describe("Copilot build-owned installation", () => { expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe(identity.commandDigest); const sorted = Object.fromEntries(Object.entries(identity.declaration).sort(([a], [b]) => a.localeCompare(b))); expect(`sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`).toBe(identity.commandDigest); - for (const [relative, field] of [...PERMISSION_POLICY_SOURCES, ...SEMANTIC_RECEIPT_SOURCES, ["copilot-policy.ts", "policySha256"], ["../../../scripts/materialize-copilot-binary.mjs", "distributionSourceSha256"], ["../../../scripts/copilot-inner-distribution.mjs", "innerDistributionSourceSha256"], ["../../../../../patches/acpx@0.13.1.patch", "acpxPatchSha256"]]) { + for (const [relative, field] of [...PERMISSION_POLICY_SOURCES, ...COPILOT_DISPLAY_SOURCES, ...SEMANTIC_RECEIPT_SOURCES, ["copilot-policy.ts", "policySha256"], ["profile-compatibility.ts", "profileCompatibilitySourceSha256"], ["provider-assets-root.ts", "assetOwnershipSourceSha256"], ["../../../scripts/install-copilot-assets.mjs", "assetInstallerSourceSha256"], ["../../../scripts/materialize-copilot-binary.mjs", "distributionSourceSha256"], ["../../../scripts/copilot-inner-distribution.mjs", "innerDistributionSourceSha256"], ["../../../../../patches/acpx@0.13.1.patch", "acpxPatchSha256"]]) { expect(createHash("sha256").update(readFileSync(new URL(relative!, import.meta.url))).digest("hex")).toBe(identity.declaration[field!]); } - expect(readFileSync(new URL("../../../acpx-profiles.json", import.meta.url), "utf8")).toContain(identity.commandDigest); + const manifest = JSON.parse(readFileSync(new URL("../../../acpx-profiles.json", import.meta.url), "utf8")); + expect(manifest.profiles.copilot.commandDigest).toBe(identity.commandDigest); + expect(readFileSync(new URL("../../../scripts/build-copilot-distribution.mjs", import.meta.url), "utf8")).toContain("profiles.profiles.copilot.commandDigest"); expect(readFileSync(new URL("../../../runner/crates/runner-core/src/generated_acpx_profiles.rs", import.meta.url), "utf8")).toContain(identity.commandDigest); }); it("binds the complete executable permission-policy import closure", async () => { @@ -118,8 +102,8 @@ describe("Copilot build-owned installation", () => { const bound = new Set(PERMISSION_POLICY_SOURCES.map(([path]) => new URL(path, import.meta.url).href)); expect([...closure].filter(path => !bound.has(path))).toEqual(["negative-control:unbound-policy"]); }); - it.each([...PERMISSION_POLICY_SOURCES, ...SEMANTIC_RECEIPT_SOURCES])("changing %s changes the candidate identity", (_path, field) => { - const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v17-identity.json", import.meta.url), "utf8")); + it.each([...PERMISSION_POLICY_SOURCES, ...COPILOT_DISPLAY_SOURCES, ...SEMANTIC_RECEIPT_SOURCES])("changing %s changes the candidate identity", (_path, field) => { + const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v35-identity.json", import.meta.url), "utf8")); identity.declaration[field] = "0".repeat(64); const sorted = Object.fromEntries(Object.entries(identity.declaration).sort(([a], [b]) => a.localeCompare(b))); expect(`sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`).not.toBe(identity.commandDigest); @@ -165,16 +149,4 @@ describe("Copilot build-owned installation", () => { } expect(verifyNativeAcpxInstallation).not.toHaveBeenCalled(); }); - it("rejects the historical v12 digest even when the caller supplies the current version", async () => { - const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v12-identity.json", import.meta.url), "utf8")); - vi.mocked(verifyNativeAcpxInstallation).mockClear(); - for (const override of [ - { commandDigest: prior.commandDigest }, - { agentProfileVersion: prior.declaration.agentProfileVersion }, - { commandDigest: prior.commandDigest, agentProfileVersion: prior.declaration.agentProfileVersion }, - ]) { - await expect(verifyCopilotInstallation({ ...QUALIFIED_ACPX_PROFILES.copilot, ...override })).rejects.toThrow("exact pinned"); - } - expect(verifyNativeAcpxInstallation).not.toHaveBeenCalled(); - }); }); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-profile.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-profile.test.ts index 826927436e..581f6548e7 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-profile.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-profile.test.ts @@ -1,7 +1,17 @@ import { describe, expect, it } from "vitest"; -import { classifyCopilotFailure, copilotConfiguration, copilotSandboxEnvironment, COPILOT_LAUNCH_ARGUMENTS } from "./copilot-profile.js"; +import { classifyCopilotFailure, composeCopilotSystemInstructions, COPILOT_TASK_ORIENTATION_INSTRUCTIONS, copilotConfiguration, copilotSandboxEnvironment, COPILOT_LAUNCH_ARGUMENTS } from "./copilot-profile.js"; describe("Copilot admission policy", () => { + it("bounds the complete native instruction file without retaining prior instructions", () => { + const suffix = `\n\n# Paperclip task orientation\n${COPILOT_TASK_ORIENTATION_INSTRUCTIONS}`; + expect(composeCopilotSystemInstructions("Current instructions.")).toBe(`Current instructions.${suffix}`); + expect(composeCopilotSystemInstructions("")).toBe(suffix); + const remaining = 32 * 1024 - Buffer.byteLength(`${suffix}\n`); + expect(Buffer.byteLength(`${composeCopilotSystemInstructions("x".repeat(remaining))}\n`)).toBe(32 * 1024); + expect(() => composeCopilotSystemInstructions("x".repeat(remaining + 1))).toThrow("bounded size"); + expect(() => composeCopilotSystemInstructions("é".repeat(Math.floor(remaining / 2) + 1))).toThrow("bounded size"); + expect(() => composeCopilotSystemInstructions("embedded\0instruction")).toThrow("bounded size"); + }); it("uses ACP directly without disabling questions or bypassing permissions", () => { expect(COPILOT_LAUNCH_ARGUMENTS.slice(0, 2)).toEqual(["--acp", "--stdio"]); expect(COPILOT_LAUNCH_ARGUMENTS).toContain("--no-auto-update"); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-profile.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-profile.ts index 73c8d599a0..379a72b9ab 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-profile.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-profile.ts @@ -7,6 +7,15 @@ export const COPILOT_SYSTEM_INSTRUCTION_DELIVERY = "COPILOT_HOME/copilot-instruc export const COPILOT_CREDENTIAL_ENVIRONMENT_NAME = "COPILOT_GITHUB_TOKEN" as const; export const COPILOT_TASK_ORIENTATION_INSTRUCTIONS = "At the start of every task turn, use the paperclip MCP server's dedicated get_task_context tool with {} and wait for its successful result before task work, report_progress, or any other task-dependent mutation. This is an advertised MCP tool, not an HTTP API route: do not use search_api or call_api to discover or substitute for get_task_context. Loaded instructions, seed state, task descriptions, and prior-turn context do not replace this read. Decide the next action from the returned context. Do not issue the context read and a dependent action in parallel. If this tool is unavailable or its read fails, report the failure and do not perform dependent actions." as const; +/** Compose Copilot-only guidance without changing other providers' sandbox identity. */ +export function composeCopilotSystemInstructions(instructions: string): string { + const composed = `${instructions}\n\n# Paperclip task orientation\n${COPILOT_TASK_ORIENTATION_INSTRUCTIONS}`; + if (composed.includes("\0") || Buffer.byteLength(`${composed}\n`) > 32 * 1024) { + throw new Error("Provider runtime instructions exceed their bounded size"); + } + return composed; +} + // Keep permission callbacks enabled even when Paperclip's policy is approve-all. // In particular COPILOT_ALLOW_ALL=true also trusts workspace hooks/plugins/MCP. export const COPILOT_LAUNCH_ARGUMENTS = Object.freeze([ diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-runtime-host.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-runtime-host.test.ts index 2ce8ed5c2b..536220811f 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-runtime-host.test.ts @@ -2,6 +2,7 @@ import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { expect, it, vi } from "vitest"; +import { COPILOT_TASK_ORIENTATION_INSTRUCTIONS } from "./copilot-profile.js"; import { AcpxRuntimeHost, type AcpxRuntimeHostDependencies, type AcpxRuntimePortOpenOptions } from "./runtime-host.js"; it("reports missing Copilot authentication before spawn and releases admission ownership for retry", async () => { @@ -66,24 +67,25 @@ it("does not refresh Copilot instructions for a contender rejected by the lifeti normalizedSessionId: "same-native-session", runtimeDirectory, workingDirectory, providerPolicy: { readOnly: false }, environment: { COPILOT_GITHUB_TOKEN: "synthetic-fixture" }, }; + const composed = (value: string) => `${value}\n\n# Paperclip task orientation\n${COPILOT_TASK_ORIENTATION_INSTRUCTIONS}\n`; const winner = AcpxRuntimeHost.open({ ...options, systemInstructions: "Winner instructions." }, dependencies); // Attach the rejection handler before deliberately releasing the failed launch. const winnerResult = expect(winner).rejects.toThrow("fixture stops before native process launch"); try { await winnerEntered; - expect(await readFile(instructionPath, "utf8")).toBe("Winner instructions.\n"); + expect(await readFile(instructionPath, "utf8")).toBe(composed("Winner instructions.")); await expect(AcpxRuntimeHost.open({ ...options, systemInstructions: "Losing contender." }, dependencies)) .rejects.toThrow("already has an active lease"); expect(openRuntime).toHaveBeenCalledOnce(); - expect(await readFile(instructionPath, "utf8")).toBe("Winner instructions.\n"); + expect(await readFile(instructionPath, "utf8")).toBe(composed("Winner instructions.")); releaseWinner(); await winnerResult; await expect(AcpxRuntimeHost.open({ ...options, systemInstructions: "New owner instructions." }, dependencies)) .rejects.toThrow("fixture stops before native process launch"); - expect(await readFile(instructionPath, "utf8")).toBe("New owner instructions.\n"); + expect(await readFile(instructionPath, "utf8")).toBe(composed("New owner instructions.")); await expect(AcpxRuntimeHost.open({ ...options, environment: {}, systemInstructions: "Unauthenticated admission." }, dependencies)) .rejects.toMatchObject({ code: "COPILOT_AUTH_REQUIRED" }); - expect(await readFile(instructionPath, "utf8")).toBe("New owner instructions.\n"); + expect(await readFile(instructionPath, "utf8")).toBe(composed("New owner instructions.")); } finally { releaseWinner(); await winnerResult; diff --git a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts index e63e7896fd..593baba1e5 100644 --- a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts @@ -48,12 +48,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "protocolVersion": 1, "acpxVersion": "0.13.1", "agent": "copilot", - "agentProfileVersion": 34, + "agentProfileVersion": 35, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:6dde98a9dbadf22f17ca28f133dbe2a65fa199827741190f569704e44bdc20ed", + "commandDigest": "sha256:6ecb42102c9440e5c8fe5b462b894f9457f255b29f8c584d8b713057cffe4327", "permissionPolicy": "interactive" }, "claude": { diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index 0f441eb29b..84c652d256 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -1,3 +1,4 @@ +import { composeCopilotSystemInstructions } from "./copilot-profile.js"; import { piProviderConfiguration } from "./pi-provider-config.js"; import { withAcpxTurnCancellation } from "./turn-cancellation.js"; import { resolveAcpxProviderMode } from "./provider-mode.js"; @@ -497,7 +498,10 @@ export class AcpxRuntimeHost { // Do not race this write against cancellation: retain the lifetime lease // until the atomic refresh settles, then honor an intervening abort. options.signal?.throwIfAborted(); - await refreshCopilotSystemInstructions(sandbox, boundedInstructions(options.systemInstructions)); + await refreshCopilotSystemInstructions( + sandbox, + composeCopilotSystemInstructions(boundedInstructions(options.systemInstructions)), + ); options.signal?.throwIfAborted(); } if (options.agent === "pi") { diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts index ab14aa666f..2c4bfa3b8e 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts @@ -1,3 +1,4 @@ +import { composeCopilotSystemInstructions, COPILOT_TASK_ORIENTATION_INSTRUCTIONS } from "./copilot-profile.js"; import { configuredEnvironmentProjection } from "../../configured-environment.js"; import { chmod, @@ -429,15 +430,16 @@ it("refreshes Copilot native instructions privately on every admission, includin const sandbox = await prepareAcpxRuntimeSandbox({ binding: fixture.binding, agent: "copilot", providerPolicy: { readOnly: false, systemInstructions }, }); - await refreshCopilotSystemInstructions(sandbox, systemInstructions); + await refreshCopilotSystemInstructions(sandbox, composeCopilotSystemInstructions(systemInstructions)); return sandbox; }; + const composed = (value: string) => `${value}\n\n# Paperclip task orientation\n${COPILOT_TASK_ORIENTATION_INSTRUCTIONS}\n`; const first = await prepare("Original registered instructions."); const path = join(first.agentHomeDirectory, "copilot-instructions.md"); - expect(await readFile(path, "utf8")).toBe("Original registered instructions.\n"); + expect(await readFile(path, "utf8")).toBe(composed("Original registered instructions.")); await prepareAcpxRuntimeSandbox({ binding: fixture.binding, agent: "copilot", providerPolicy: { readOnly: false, systemInstructions: "Contender must not write during preparation." } }); - expect(await readFile(path, "utf8")).toBe("Original registered instructions.\n"); + expect(await readFile(path, "utf8")).toBe(composed("Original registered instructions.")); expect((await stat(path)).mode & 0o777).toBe(0o600); expect(first.protectedPaths).toContain(dirname(dirname(first.root))); expect(path.startsWith(`${first.protectedPaths[0]}/`)).toBe(true); @@ -448,11 +450,11 @@ it("refreshes Copilot native instructions privately on every admission, includin await prepare("Current registered instructions."); expect((await lstat(path)).isSymbolicLink()).toBe(false); expect(await readFile(target, "utf8")).toBe("Do not overwrite."); - expect(await readFile(path, "utf8")).toBe("Current registered instructions.\n"); + expect(await readFile(path, "utf8")).toBe(composed("Current registered instructions.")); await prepare(""); - expect(await readFile(path, "utf8")).toBe("\n"); + expect(await readFile(path, "utf8")).toBe(composed("")); await expect(prepare("x".repeat(32 * 1024 + 1))).rejects.toThrow("bounded size"); - expect(await readFile(path, "utf8")).toBe("\n"); + expect(await readFile(path, "utf8")).toBe(composed("")); }); async function sandboxFixture(agent: "pi" | "claude" | "codex" | "grok" | "cursor" | "copilot") { diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts index 3fd9b1ba8e..3e0e52d250 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -1,6 +1,6 @@ import { piProviderConfiguration } from "./pi-provider-config.js"; import { configuredEnvironmentKeys } from "../../configured-environment.js"; -import { COPILOT_SYSTEM_INSTRUCTIONS_FILE, COPILOT_TASK_ORIENTATION_INSTRUCTIONS } from "./copilot-profile.js"; +import { COPILOT_SYSTEM_INSTRUCTIONS_FILE } from "./copilot-profile.js"; import { randomBytes } from "node:crypto"; import { constants, @@ -646,15 +646,13 @@ export async function refreshCopilotSystemInstructions( sandbox: Pick, instructions: string, ): Promise { - const current = `${instructions}\n\n# Paperclip task orientation\n${COPILOT_TASK_ORIENTATION_INSTRUCTIONS}\n`; - if (current.includes("\0") || Buffer.byteLength(current) > 32 * 1024) { + if (instructions.includes("\0") || Buffer.byteLength(instructions) > 32 * 1024) { throw new Error("Provider runtime instructions exceed their bounded size"); } - // Native Copilot reloads this file on session/load. Empty caller text clears - // old caller instructions while retaining the current task-orientation rule. + // Native Copilot reloads this file on session/load. Empty text clears old text. await writePrivateFile( join(sandbox.agentHomeDirectory, COPILOT_SYSTEM_INSTRUCTIONS_FILE), - current, + `${instructions}\n`, ); } diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v35-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v35-identity.json new file mode 100644 index 0000000000..504c2d251c --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v35-identity.json @@ -0,0 +1,81 @@ +{ + "commandDigest": "sha256:6ecb42102c9440e5c8fe5b462b894f9457f255b29f8c584d8b713057cffe4327", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 35, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107", + "agentFilesBinding": "registered-runtime-context-v1", + "systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a", + "upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0", + "patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679", + "messageIdentityContract": "copilot-native-message-id-v1", + "ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1", + "permissionContextContract": "copilot-edit-permission-context-v1", + "permissionContextSourceSha256": "de2d38d702b3c574161af0c0c04790fb6f6347916eb3916ce7a131a8c229121b", + "permissionAdapterSourceSha256": "ee5929ed2ee647ad0369465dee9d51a00b2dd28e1ff735b43e969ddc9c164668", + "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", + "permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "cf136da18e2cdbefae8e01d23d2c93f64eb7aebccaec938e84c2887c888f87ee", + "toolEvidenceSourceSha256": "49d7d09eb6957e596a173b0afd763544afe582f0c1f05cc0992fe51916a8fdb3", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "7fd06053db98cc763c7205424069a5286c9d4cfefb3d197a4a765c693dccb7e6", + "semanticDirectDriverSourceSha256": "77882e21ad45b66a688fd1933dbd4fc89fad38e8c1f092805cb9485bdd5223c5", + "semanticValidationSourceSha256": "3b0ac8d3f3cb92747d0bcea472ab0419a140e1cfdc43047e2c22542acc2d105f", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "fa2b0c81cb9a62965839c905db81b930794774d8d557a01bce077dae5830b594", + "semanticValidatorsSourceSha256": "4f0219ed00938c5d8bc1b11532ecab9c13230012b23bfd6b7dd6b992e58e98d5", + "semanticSchemaBundleSourceSha256": "fe82ac2cbf194d31ac801de6a3cacfb165cb34146c5c26557f283b8c87a5f01e", + "semanticSidecarProtocolSourceSha256": "334b67857bd81e9924186890258cb2ad41f92b1ef68ec60fa5c5d9fbb277673c", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1", + "displayEventSourceSha256": "d72f9a6035c247bc6a42c067c05753076c54322c4b58984e93922f3325dc6877", + "displayProjectionSourceSha256": "75952606b26dd93bbc582c1f0a4424487377272ea745fb3a27ceae031fd5dc7e", + "configuredEnvironmentSourceSha256": "a03575b3a737dea60da94cf0fa1feed80719a9aaa7f09456eb57a845d9e8e087", + "configuredEnvironmentRustSourceSha256": "55d44e48e217385b5c64b1b7d8df16dda62319ca3fb524e452fd78c9ed804d3b", + "profileActivitySourceSha256": "4fdb6b0f9125fd8372ba6f27945fa5c8a3d3b2ec891df544092e3dc7a75d5282", + "profileExtensionsSourceSha256": "a80ddc95eb4505095bfdbbf85c758199949ab20be9c84b34c77f762eb7180571", + "profileInstallationSourceSha256": "e4aacb08015fa9104dce2b0c7d150cc432231c8a3e07d2cec2a325b388f7f382", + "attachmentPolicySourceSha256": "4f7638b7cc64e18f6a4ba33fd46de2b3d49225e32aeab41f5c695b268fa4f8e2", + "singleReadEvidenceSourceSha256": "4069393405089bde613cecec04f5123c426594811da4c668bd96f0e7e283550f", + "nativeSessionRuntimeSourceSha256": "aac818c1cf50a25899ae95cafc70f093c8cd8af37d19f88cbb7847b4d202d2b6", + "nativeRuntimeContextSourceSha256": "0b350f5f4044f342b974254738bab45a470c24db9352b04c92d785d26e7853c8", + "nativeSessionContractSourceSha256": "93251b1eadc686e9d8339d4dfc50b995a3d0a9e6ad8aedc6f2bdf7e30c16138d", + "harnessSessionContractSourceSha256": "9e3d62b712cae91168c96d11ff850b97a144809b5cddfe08ec9a91229e9bfc40", + "harnessBackendSourceSha256": "46fd6523f986d5406eca3b420fe920f1d51a6435fb7afc2acf4d21be13af447f", + "harnessSessionSourceSha256": "4dff9bb3370c5560cfc910a7ee814a27725b469c3b1b5dc021e3c0b158ebe746", + "transportContractSourceSha256": "5cd208fe11c3bedd476494b0609fb32efbabe4a1170e2c5854d0aa5e429bd6f4", + "nativeBackendSourceSha256": "294b9d830bc58c05985087875523db133d32cf999703140fd927e843399424db", + "nativeBackendFactorySourceSha256": "ce22825a53669f8c2db5aaa890bae94af19ede22295bda4ed7987a31d804cd8a", + "nativeInstructionsSourceSha256": "ea85be2aca8dfbd6b4042dcd7b1ed9db9a57baf8c379649a3f5d7d80ca54a6a2", + "runtimeRequestBridgeSourceSha256": "a66ee14f858ccf75c69dfbce132900a30724fe1e6baffc26b02d58a6bc5924ac", + "runtimeRequestFacadeSourceSha256": "e1993906ac9f47eb4ea04ae815db869ecde72f582af460cc0080308670612c8f", + "runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211", + "guardedInstallationSourceSha256": "2881b655112f165320a73753385cd54286139a0273a5fa30c1b43fc3b5981a25", + "nativeExecutionSourceSha256": "9c702e518862c396ccfa12d84d11cb8dec1fc064c595359e31cc3e299a7378cc", + "profileCompatibilitySourceSha256": "20f8aaa363496a4cbede0f33aeacf869fb50171eaee2d63a892c65b3bda4c6a1", + "installationSourceSha256": "a1c7c6f103b898216973e77ffbac0ef7572e250aa574d1a4065270e9dfa0dc2d", + "assetOwnershipSourceSha256": "1785e3f31e5dad5a13b53e25d8cf9482a9bd89a27b6c127830aed6b81acc605c", + "assetInstallerSourceSha256": "d3bd9514e5920d0cc3567aa50487fb534c57a6f66b8da0d3f22c5f6e499c841f", + "credentialBindingSourceSha256": "f8dc40ea1aa894bd44754a35a8f65fbcaa2f4c7ffc88cc5fdaa965e0d378d9bc", + "sidecarErrorSourceSha256": "23f3dbdc1ce488c690d218a854c820f4b4f3edd822bad574d67707314f8c512a", + "profileSourceSha256": "f5538b3dbefa7634a332b87d5ededcefb56aa7e5790cede1c7a0d948a548a456", + "metadataProbeSourceSha256": "826e74747a89b83a6f546ee1355de64ae5265eb920b50ae498a41dd561a493ee", + "permissionToolIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "runtimeHostSourceSha256": "fd62912cdbaf76e73664024fcec835e899e967a4889fdfdccde7eb215c545f8a", + "runtimeAdapterSourceSha256": "3aea6448dc6474e465e5267b61798ad5449bdaec3f4a41bc4be831426893a9bb", + "runtimeSandboxSourceSha256": "1c1ddb0b24c3417250ce161cda897ba59b1adce7c7609ed553be298464e6c1c4", + "cancellationSourceSha256": "a07d1a9e982dec0ae130c1ad1efa5bc75c6a39e013b730ba3ee45256f09ceeca", + "agentFilesSourceSha256": "fb32f6badc13dfa22f231479509b631b2f0eba0c99b1ab9f3cd5c0c601aa03ba", + "nativeDistributionSourceSha256": "aed327c389e957483a503cb1bb8b247ee7cd7ff4b528fb4567c39bcb4577690b" + } +} diff --git a/server/src/services/native-runtime/runtime-mode.test.ts b/server/src/services/native-runtime/runtime-mode.test.ts index 11eb8d808a..a2c9de4e55 100644 --- a/server/src/services/native-runtime/runtime-mode.test.ts +++ b/server/src/services/native-runtime/runtime-mode.test.ts @@ -107,16 +107,23 @@ describe("resolveNativeRuntimeMode", () => { kind: "native", profile: { backend: "acpx_runtime" }, }); + expect(resolveNativeRuntimeMode({ + ...eligible, + adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" }, + })).toMatchObject({ + kind: "native", + profile: { backend: "acpx_runtime" }, + }); }); - it("rejects malformed OpenCode and unqualified ACPX profiles", () => { + it("rejects malformed OpenCode and unregistered ACPX profiles", () => { expect(() => resolveNativeRuntimeMode({ ...eligible, adapterConfig: { provider: "opencode", model: "gpt-5.6-sol" }, })).toThrow(expect.objectContaining({ code: "paperclip_runner_opencode_model_invalid", })); - for (const acpxAgent of ["copilot"]) { + for (const acpxAgent of ["unregistered-acpx-agent"]) { expect(() => resolveNativeRuntimeMode({ ...eligible, adapterConfig: { provider: "acpx", acpxAgent, model: "explicit-provider-model" }, From 055459e1150e0a172f962ae98669ab937a40706a Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 9 Oct 2026 11:42:44 -0500 Subject: [PATCH 2/3] Keep guarded recovery fixture synthetic under installed Codex selection Bind its fake provider at the command resolver and retain bounded diagnostics for failed terminal results. Preserve retirement and archived mutation assertions. Co-Authored-By: Paperclip --- .../native-runtime/native-session-resume.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/server/src/services/native-runtime/native-session-resume.test.ts b/server/src/services/native-runtime/native-session-resume.test.ts index 41b99c90bb..80236ceb6a 100644 --- a/server/src/services/native-runtime/native-session-resume.test.ts +++ b/server/src/services/native-runtime/native-session-resume.test.ts @@ -734,6 +734,7 @@ const recoveryFakeCodex = resolve( const previousStateBase = process.env.PAPERCLIP_RUNNER_STATE_DIR; const server = createServer(); let firstSession: NativeSession | undefined; + let restoreCodexCommand: (() => void) | undefined; const ownedSessions = new Set(); const ownedSpawns: Array<{ pid: number; processGroupId: number | null; startedAt: string }> = []; const onSpawn = async (meta: typeof ownedSpawns[number]) => { ownedSpawns.push(meta); }; @@ -759,6 +760,11 @@ const recoveryFakeCodex = resolve( `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(recoveryFakeCodex)} "$CODEX_HOME/fake-state.json" 16\n`, ); await chmod(command, 0o700); + // Installed dependency selection intentionally precedes PATH on mainline. + // Bind this fixture's synthetic provider explicitly at that boundary. + const codexCommandModule = await import("../../../../packages/paperclip-runner/src/drivers/codex/codex-command.js"); + const codexCommandSelection = vi.spyOn(codexCommandModule, "resolveCodexCommand").mockReturnValue(command); + restoreCodexCommand = () => codexCommandSelection.mockRestore(); await new Promise((done) => server.listen(0, "127.0.0.1", done)); const address = server.address(); if (!address || typeof address === "string") @@ -1048,6 +1054,11 @@ const recoveryFakeCodex = resolve( async onContinuityBreak(value) { continuity = value; }, + }).then((result) => { + if (result.terminal.runTerminalState !== "succeeded") { + throw new Error(`Disposable recovery fixture failed: ${JSON.stringify(result)}`); + } + return result; }).catch(async (error: unknown) => { const eventRows = await db .select({ @@ -1227,6 +1238,7 @@ const recoveryFakeCodex = resolve( Promise.resolve().then(() => session.close({ reason: "Recovery fixture cleanup" })), )); const closeFailures = closed.filter((result) => result.status === "rejected"); + restoreCodexCommand?.(); runnerPrpWebSocketInternals.resetForTests(); server.closeAllConnections(); await new Promise((done) => server.close(() => done())); From 1b78be9c1d68dbe26309f476127747e2c289b8f1 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 9 Oct 2026 11:43:34 -0500 Subject: [PATCH 3/3] Preserve Copilot model access and durable connection catalog source Allow company task authors to discover models through existing saved-connection access checks. Preserve tenant isolation, add durable GitHub authoring data, and synchronize ordinary UI and credential admission expectations. Co-Authored-By: Paperclip --- .../src/paperclip-runner-permissions.test.ts | 7 +- .../shared/src/app-definitions/github.json | 2 +- scripts/app-definition-overrides/github.json | 118 ++++++++++++++++++ .../agent-test-environment-routes.test.ts | 45 ++++++- server/src/routes/agents.ts | 2 +- ui/src/adapters/config-sections.test.tsx | 2 +- 6 files changed, 167 insertions(+), 9 deletions(-) create mode 100644 scripts/app-definition-overrides/github.json diff --git a/packages/adapter-utils/src/paperclip-runner-permissions.test.ts b/packages/adapter-utils/src/paperclip-runner-permissions.test.ts index 31bc6acc4b..cd61414cc4 100644 --- a/packages/adapter-utils/src/paperclip-runner-permissions.test.ts +++ b/packages/adapter-utils/src/paperclip-runner-permissions.test.ts @@ -12,12 +12,11 @@ import { } from "./paperclip-runner-permissions.js"; describe("Paperclip Runner permission defaults", () => { - it("admits Pi with provider credentials while keeping Copilot pending", () => { + it("marks Pi and Copilot qualified with their provider credential bindings", () => { expect(PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === "pi")) .toMatchObject({ qualified: true, credentialEnvironment: expect.arrayContaining(["OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY"]) }); - for (const agent of ["copilot"]) { - expect(PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === agent)?.qualified).toBe(false); - } + expect(PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === "copilot")) + .toMatchObject({ qualified: true, credentialEnvironment: ["COPILOT_GITHUB_TOKEN"] }); }); it("defaults Codex to the only qualified non-interactive mode", () => { expect(resolvePaperclipRunnerPermissionMode("codex", undefined)).toBe( diff --git a/packages/shared/src/app-definitions/github.json b/packages/shared/src/app-definitions/github.json index 6f65405ebd..1e7b359b58 100644 --- a/packages/shared/src/app-definitions/github.json +++ b/packages/shared/src/app-definitions/github.json @@ -69,7 +69,7 @@ "required": true, "placeholder": "github_pat_...", "secret": true, - "helperMd": "Repository contents, issues, pull requests and other granted toolsets; selected repos and installation/PAT permissions apply. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + "helperMd": "Repository contents, issues, pull requests and other granted toolsets; selected repos and installation/PAT permissions apply. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." } ], "keyPlacement": { diff --git a/scripts/app-definition-overrides/github.json b/scripts/app-definition-overrides/github.json new file mode 100644 index 0000000000..69ca1d712d --- /dev/null +++ b/scripts/app-definition-overrides/github.json @@ -0,0 +1,118 @@ +{ + "schemaVersion": 1, + "slug": "github", + "name": "GitHub", + "description": "Connect GitHub repository access or authenticate GitHub Copilot agents.", + "categories": [ + "developer", + "ai" + ], + "featured": true, + "urlPatterns": [ + "https://api.githubcopilot.com/mcp/*", + "https://github.com/*" + ], + "methods": [ + { + "key": "managed", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "platform_shared" + ], + "whenToUse": "Connect your GitHub account for durable MCP, shell Git, gh, and repository access.", + "defaults": { + "serverUrl": "https://api.githubcopilot.com/mcp/" + }, + "guidanceMd": "Authorize Paperclip, then choose selected repositories in GitHub. You can edit repository access later from GitHub's installation settings.", + "riskTier": "S3", + "label": "Connect GitHub", + "purpose": "tool", + "oauthStrategy": "paperclip_cloud_connector", + "connectorProfile": "github.code", + "grantKinds": [ + "user", + "agent" + ], + "warnings": [ + "Shell Git and gh receive this identity for the run and are not constrained by per-tool Ask-first controls." + ], + "requiredResourceFilters": [ + "organization", + "repository" + ] + }, + { + "key": "mcp-key", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use the provider-hosted connection for the quickest setup.", + "defaults": { + "serverUrl": "https://api.githubcopilot.com/mcp/" + }, + "guidanceMd": "Create a fine-grained token limited to the repositories agents should use.", + "riskTier": "S3", + "label": "Personal access token (advanced)", + "purpose": "tool", + "credentialFields": [ + { + "key": "authorization", + "label": "GitHub token", + "type": "password", + "required": true, + "placeholder": "github_pat_...", + "secret": true, + "helperMd": "Repository contents, issues, pull requests and other granted toolsets; selected repos and installation/PAT permissions apply. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + } + ], + "keyPlacement": { + "location": "header", + "name": "Authorization", + "prefix": "Bearer " + }, + "requiredResourceFilters": [ + "organization", + "repository" + ] + }, + { + "key": "copilot-token", + "label": "GitHub Copilot personal access token", + "purpose": "ai", + "transport": "runtime_auth", + "auth": "api_key", + "ai": { + "provider": "github", + "method": "api_key" + }, + "grantKinds": [ + "user", + "organization" + ], + "ownershipModes": [ + "customer" + ], + "whenToUse": "Authenticate the GitHub Copilot agent runtime with a personal or explicitly shared account.", + "guidanceMd": "Create a personal fine-grained token with the Copilot Requests permission. Copilot authentication is separate from repository and MCP access. [GitHub authentication instructions](https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/authenticate-copilot-cli).", + "riskTier": "S3", + "credentialFields": [ + { + "key": "apiKey", + "label": "Personal access token", + "type": "password", + "required": true, + "placeholder": "github_pat_...", + "secret": true, + "helperMd": "Use a personal fine-grained token with Copilot Requests permission." + } + ], + "keyPlacement": { + "location": "env", + "name": "COPILOT_GITHUB_TOKEN" + } + } + ] +} diff --git a/server/src/__tests__/agent-test-environment-routes.test.ts b/server/src/__tests__/agent-test-environment-routes.test.ts index 8904af21be..9111c8d052 100644 --- a/server/src/__tests__/agent-test-environment-routes.test.ts +++ b/server/src/__tests__/agent-test-environment-routes.test.ts @@ -122,13 +122,16 @@ vi.mock("../routes/ai-connections.js", async (importOriginal) => ({ validateAiApiKey: mockValidateAiApiKey, })); const mockMarkAuthenticationFailed = vi.hoisted(() => vi.fn(async () => undefined)); +const mockSelectAiConnection = vi.hoisted(() => vi.fn(async (_input: unknown) => ({ connectionId: "copilot-connection", grantId: "copilot-grant" }))); +const mockAiCredential = vi.hoisted(() => vi.fn(async (_selection: unknown) => "synthetic-copilot-token")); +const mockCopilotModels = vi.hoisted(() => vi.fn(async () => ({ models: [{ id: "gpt-5.6-luna", label: "GPT-5.6 Luna" }], promptSent: false }))); const mockCopilotProbe = vi.hoisted(() => vi.fn(async () => ({ status: "verified", promptSent: false }))); vi.mock("../services/copilot-connection-probe.js", async importOriginal => ({ - ...(await importOriginal()), probeCopilotExecutionTarget: mockCopilotProbe, + ...(await importOriginal()), probeCopilotExecutionTarget: mockCopilotProbe, probeCopilotConnection: mockCopilotModels, })); vi.mock("../services/ai-connections.js", async (importOriginal) => ({ ...(await importOriginal()), - aiConnectionService: () => ({ markAuthenticationFailed: mockMarkAuthenticationFailed }), + aiConnectionService: () => ({ markAuthenticationFailed: mockMarkAuthenticationFailed, select: mockSelectAiConnection, credential: mockAiCredential }), })); function mockManagedRuntime(method: "api_key" | "subscription") { @@ -259,6 +262,44 @@ describe("agent test-environment route", () => { await unregisterTestAdapter("external_test"); }); + it("lets a company task author discover Copilot models without agent configuration permission", async () => { + mockAccessService.decide.mockResolvedValue({ allowed: false, explanation: "No configuration grant" }); + mockAccessService.canUser.mockResolvedValue(false); + mockAgentService.getById.mockResolvedValue({ id: "agent-1", companyId: "company-1" }); + const response = await request(await createApp()).get("/api/companies/company-1/adapters/paperclip_runner/models") + .query({ provider: "acpx", acpxAgent: "copilot", agentId: "agent-1" }); + expect(response.status).toBe(200); + expect(response.body).toEqual([{ id: "gpt-5.6-luna", label: "GPT-5.6 Luna" }]); + expect(response.headers["cache-control"]).toBe("no-store"); + expect(mockAccessService.decide).not.toHaveBeenCalled(); + expect(mockSelectAiConnection).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ + companyId: "company-1", userId: "local-board", agentId: "agent-1", runnerProvider: "acpx", acpxAgent: "copilot", + binding: { provider: "github", method: "api_key", mode: "responsible_user" }, allowUninstalledShared: false, + })); + expect(mockCopilotModels).toHaveBeenCalledExactlyOnceWith(expect.anything(), "company-1", "synthetic-copilot-token", null, undefined, expect.anything()); + }); + + it("refuses model discovery for a foreign agent before selecting credentials", async () => { + mockAgentService.getById.mockResolvedValue({ id: "agent-foreign", companyId: "company-2" }); + const response = await request(await createApp()).get("/api/companies/company-1/adapters/paperclip_runner/models") + .query({ provider: "acpx", acpxAgent: "copilot", agentId: "agent-foreign" }); + expect(response.status).toBe(404); + expect(mockSelectAiConnection).not.toHaveBeenCalled(); + expect(mockAiCredential).not.toHaveBeenCalled(); + expect(mockCopilotModels).not.toHaveBeenCalled(); + }); + + it("keeps saved connection access enforced before Copilot model probing", async () => { + const { forbidden } = await import("../errors.js"); + mockAgentService.getById.mockResolvedValue({ id: "agent-1", companyId: "company-1" }); + mockSelectAiConnection.mockRejectedValueOnce(forbidden("Saved connection access denied")); + const response = await request(await createApp()).get("/api/companies/company-1/adapters/paperclip_runner/models") + .query({ provider: "acpx", acpxAgent: "copilot", agentId: "agent-1" }); + expect(response.status).toBe(403); + expect(mockAiCredential).not.toHaveBeenCalled(); + expect(mockCopilotModels).not.toHaveBeenCalled(); + }); + it("tests the instance default sandbox when the agent inherits its environment", async () => { const environmentId = "11111111-1111-4111-8111-111111111111"; mockInstanceSettingsService.get.mockResolvedValue({ defaultEnvironmentId: environmentId }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index a90a2e6950..1b605020a4 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -3415,7 +3415,7 @@ export function agentRoutes( if (modelAgentId) { const modelAgent = await svc.getById(modelAgentId); if (!modelAgent || modelAgent.companyId !== companyId) throw notFound("Agent not found"); - await assertCanUpdateAgent(req, modelAgent); + await assertCanReadAgent(req, modelAgent); } else await assertCanCreateAgentsForCompany(req, companyId); const allowUninstalledShared = !modelAgentId && await canInstallSharedAiConnectionForNewAgent(db, req, companyId, binding); const service = aiConnectionService(db); diff --git a/ui/src/adapters/config-sections.test.tsx b/ui/src/adapters/config-sections.test.tsx index 7a37b0f1a9..810b409168 100644 --- a/ui/src/adapters/config-sections.test.tsx +++ b/ui/src/adapters/config-sections.test.tsx @@ -100,7 +100,7 @@ describe("adapter configuration sections", () => { expect(Array.from(options, option => ({ label: option.textContent?.trim(), disabled: option.hasAttribute("data-disabled") }))) .toEqual(expect.arrayContaining([ { label: "Claude", disabled: false }, { label: "Pi", disabled: false }, - { label: "Cursor", disabled: false }, { label: "GitHub Copilot — qualification pending", disabled: true }, + { label: "Cursor", disabled: false }, { label: "GitHub Copilot", disabled: false }, ])); expect(adapter).not.toContain("Runner lifecycle"); expect(policy).toContain("Runner lifecycle");