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/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 c9138ecb3b..87e573f04a 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -70,7 +70,7 @@ vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: vi describe("Copilot build-owned installation", () => { 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-v34-identity.json", import.meta.url), "utf8")); + 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); @@ -103,7 +103,7 @@ describe("Copilot build-owned installation", () => { expect([...closure].filter(path => !bound.has(path))).toEqual(["negative-control:unbound-policy"]); }); 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-v34-identity.json", import.meta.url), "utf8")); + 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); 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 65dbd07fb6..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 @@ -67,26 +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")).toContain(COPILOT_TASK_ORIENTATION_INSTRUCTIONS); - expect(await readFile(instructionPath, "utf8")).toContain("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")).toContain("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")).toContain(COPILOT_TASK_ORIENTATION_INSTRUCTIONS); - expect(await readFile(instructionPath, "utf8")).toContain("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")).toContain("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 66b786f359..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,4 +1,4 @@ -import { COPILOT_TASK_ORIENTATION_INSTRUCTIONS } from "./copilot-profile.js"; +import { composeCopilotSystemInstructions, COPILOT_TASK_ORIENTATION_INSTRUCTIONS } from "./copilot-profile.js"; import { configuredEnvironmentProjection } from "../../configured-environment.js"; import { chmod, @@ -430,7 +430,7 @@ 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`; 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/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/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())); 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" }, 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");