diff --git a/doc/architecture/runner-copilot-capabilities.md b/doc/architecture/runner-copilot-capabilities.md index 53448716c03..7359466cb20 100644 --- a/doc/architecture/runner-copilot-capabilities.md +++ b/doc/architecture/runner-copilot-capabilities.md @@ -105,6 +105,13 @@ is a separate lifecycle operation. ### Native instruction delivery +The current candidate is profile v4. Its declaration binds native personal-file +instruction delivery, including replacement before every admission. The isolated +`COPILOT_HOME/copilot-instructions.md` is written atomically with mode 0600 under +the protected provider home, and empty instructions replace any stale content. +Profile v3 identities are rejected before native launch; historical evidence +below remains labeled with its original profile and source. + The [native model-request probe](../../packages/paperclip-runner/test/fixtures/copilot-native-instruction-delivery-2026-09-29.json) retains a separate instruction-delivery failure: Copilot 1.0.88 ignores generic ACP `_meta.systemPrompt` on new sessions, and ACPX does not include it on load. @@ -119,7 +126,10 @@ reopening with fresh instructions, actual `session/load` and a second explicit turn completed. The second model request contained the current directory and updated entry, with neither old value in its system message. Both attempts used two synthetic loopback responses and no paid calls. This probe is not a final -built-production or authenticated qualification result. +built-production or authenticated qualification result. A follow-up +[source-level v4 probe](../../packages/paperclip-runner/test/fixtures/copilot-v4-native-instruction-delivery-2026-09-29.json) +uses the actual production sandbox writer and confirms the same new/load model +request behavior; the final frozen pack still requires independent verification. ### Native detached work is explicitly unsupported 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 cb1bde874e2..37aa7bef1ec 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 @@ -186,7 +186,7 @@ impl AcpxProviderDescriptor { "1.0.88", None, None, - "sha256:527f9cbbad2f3e75169cae70d7aa5b189200e57f1ab7f9e1523357b5606b0939", + "sha256:e7f8416b1119895c8c79307b95fc0e46312cc4821abee2a691f26c3d67f282af", ), "grok" => ( "grok-4.7", @@ -2731,7 +2731,7 @@ mod tests { "copilot", "@github/copilot", "1.0.88", - "sha256:527f9cbbad2f3e75169cae70d7aa5b189200e57f1ab7f9e1523357b5606b0939", + "sha256:e7f8416b1119895c8c79307b95fc0e46312cc4821abee2a691f26c3d67f282af", None, None, "explicit-model", @@ -2759,6 +2759,14 @@ mod tests { value["providerPolicy"] = json!({"readOnly":true}); let valid: AcpxProviderDescriptor = serde_json::from_value(value.clone()).unwrap(); valid.validate(&context()).unwrap(); + if agent == "copilot" { + let mut legacy = value.clone(); + legacy["commandDigest"] = json!( + "sha256:527f9cbbad2f3e75169cae70d7aa5b189200e57f1ab7f9e1523357b5606b0939" + ); + let legacy: AcpxProviderDescriptor = serde_json::from_value(legacy).unwrap(); + assert!(legacy.validate(&context()).is_err()); + } for field in ["model", "agentServerVersion", "commandDigest"] { let mut wrong = value.clone(); wrong[field] = json!(""); diff --git a/packages/paperclip-runner/scripts/build-copilot-distribution.mjs b/packages/paperclip-runner/scripts/build-copilot-distribution.mjs index 8b727eee3f0..ae6f4176af8 100644 --- a/packages/paperclip-runner/scripts/build-copilot-distribution.mjs +++ b/packages/paperclip-runner/scripts/build-copilot-distribution.mjs @@ -7,7 +7,7 @@ import { COPILOT_VERSION, materializePinnedCopilotBinary, resolveCopilotDistribu const MAX_ARCHIVE_BYTES = 128 * 1024 * 1024; const MAX_EXPANDED_BYTES = 384 * 1024 * 1024; -const PROFILE_DIGEST = "sha256:527f9cbbad2f3e75169cae70d7aa5b189200e57f1ab7f9e1523357b5606b0939"; +const PROFILE_DIGEST = "sha256:e7f8416b1119895c8c79307b95fc0e46312cc4821abee2a691f26c3d67f282af"; /** Build-time only; outputRoot is the exact runner-owned platform asset directory. */ export async function buildPinnedCopilotDistribution({ outputRoot, platform = process.platform, architecture = process.arch }, { fetchImpl = fetch } = {}) { 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 66cf3b83501..658db414ad0 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -4,14 +4,15 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it, vi } from "vitest"; import { verifyNativeAcpxInstallation } from "./installation-integrity.js"; import { COPILOT_CLOSURE_SHA256, verifyCopilotInstallation } from "./copilot-installation.js"; -import { COPILOT_LAUNCH_ARGUMENTS } from "./copilot-profile.js"; +import { COPILOT_LAUNCH_ARGUMENTS, COPILOT_SYSTEM_INSTRUCTION_DELIVERY } from "./copilot-profile.js"; import { QUALIFIED_ACPX_PROFILES } from "./qualified-profiles.js"; vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: vi.fn() })); describe("Copilot build-owned installation", () => { - it("admits the current v3 declaration and binds its source policy/patch hashes", () => { - const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v3-identity.json", import.meta.url), "utf8")); + it("admits the current v4 declaration and binds its source policy/patch hashes", () => { + const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v4-identity.json", import.meta.url), "utf8")); + expect(identity.declaration.systemInstructionDelivery).toBe(COPILOT_SYSTEM_INSTRUCTION_DELIVERY); expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(identity.declaration.agentProfileVersion); expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe(identity.commandDigest); const sorted = Object.fromEntries(Object.entries(identity.declaration).sort(([a], [b]) => a.localeCompare(b))); @@ -49,7 +50,7 @@ describe("Copilot build-owned installation", () => { }); it("rejects changed profile identities before native file access", async () => { vi.mocked(verifyNativeAcpxInstallation).mockClear(); - for (const override of [{ agentServerVersion: "latest" }, { commandDigest: "sha256:untrusted" }, { agent: "cursor" }, { agentProfileVersion: 1 }, { agentProfileVersion: 2 }, { agentProfileVersion: 4 }]) { + for (const override of [{ agentServerVersion: "latest" }, { commandDigest: "sha256:untrusted" }, { agent: "cursor" }, { agentProfileVersion: 1 }, { agentProfileVersion: 2 }, { agentProfileVersion: 3 }, { agentProfileVersion: 5 }]) { await expect(verifyCopilotInstallation({ ...QUALIFIED_ACPX_PROFILES.copilot, ...override } as never)).rejects.toThrow("exact pinned"); } expect(verifyNativeAcpxInstallation).not.toHaveBeenCalled(); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-profile.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-profile.ts index 3b7f713375f..4023cfcd942 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-profile.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-profile.ts @@ -2,6 +2,8 @@ import { isAbsolute, resolve } from "node:path"; /** This is an admission candidate, not a claim of live qualification. */ export const COPILOT_VERSION = "1.0.88" as const; +export const COPILOT_SYSTEM_INSTRUCTIONS_FILE = "copilot-instructions.md" as const; +export const COPILOT_SYSTEM_INSTRUCTION_DELIVERY = "COPILOT_HOME/copilot-instructions.md:replace-before-admission:v1" as const; export const COPILOT_CREDENTIAL_ENVIRONMENT_NAME = "COPILOT_GITHUB_TOKEN" as const; // Keep permission callbacks enabled even when Paperclip's policy is approve-all. diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index c217182548f..0357367925b 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -71,10 +71,10 @@ export const QUALIFIED_ACPX_PROFILES: Readonly< }, copilot: { driverKind: ACPX_DRIVER_KIND, protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION, - acpxVersion: QUALIFIED_ACPX_VERSION, agent: "copilot", agentProfileVersion: 3, + acpxVersion: QUALIFIED_ACPX_VERSION, agent: "copilot", agentProfileVersion: 4, agentServerPackage: "@github/copilot", agentServerVersion: "1.0.88", agentRuntimePackage: null, agentRuntimeVersion: null, - commandDigest: "sha256:527f9cbbad2f3e75169cae70d7aa5b189200e57f1ab7f9e1523357b5606b0939", + commandDigest: "sha256:e7f8416b1119895c8c79307b95fc0e46312cc4821abee2a691f26c3d67f282af", // Authenticated discovery has not established a qualification model. Never // turn this empty declaration into a default; callers must select an ID. qualificationModel: "", reportedModelId: "", permissionPolicy: "interactive", 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 fb80619a55d..aff59c19093 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts @@ -385,6 +385,31 @@ describe("ACPX runtime sandbox", () => { ); }); +it("refreshes Copilot native instructions privately on every admission, including removal", async () => { + const fixture = await sandboxFixture("copilot"); + const prepare = (systemInstructions: string) => prepareAcpxRuntimeSandbox({ + binding: fixture.binding, agent: "copilot", providerPolicy: { readOnly: false, systemInstructions }, + }); + 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 stat(path)).mode & 0o777).toBe(0o600); + expect(first.protectedPaths).toContain(dirname(dirname(first.root))); + expect(path.startsWith(`${first.protectedPaths[0]}/`)).toBe(true); + const target = join(fixture.root, "unrelated-file"); + await writeFile(target, "Do not overwrite."); + await rm(path); + await symlink(target, path); + 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"); + await prepare(""); + expect(await readFile(path, "utf8")).toBe("\n"); + await expect(prepare("x".repeat(32 * 1024 + 1))).rejects.toThrow("bounded size"); + expect(await readFile(path, "utf8")).toBe("\n"); +}); + async function sandboxFixture(agent: "pi" | "claude" | "codex" | "grok" | "cursor" | "copilot") { const root = await mkdtemp(join(tmpdir(), "paperclip-acpx-sandbox-")); temporaryDirectories.push(root); diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts index 8ecdbc88ede..cfd1f2aa745 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -1,3 +1,4 @@ +import { COPILOT_SYSTEM_INSTRUCTIONS_FILE } from "./copilot-profile.js"; import { randomBytes } from "node:crypto"; import { constants, @@ -433,6 +434,12 @@ export async function prepareAcpxRuntimeSandbox(input: { ); } if (input.agent === "copilot") { + // Native Copilot ignores ACP systemPrompt metadata. Its personal instruction + // file is reloaded on session/load; replace it even when instructions clear. + await writePrivateFile( + join(agentHomeDirectory, COPILOT_SYSTEM_INSTRUCTIONS_FILE), + `${policy.systemInstructions}\n`, + ); await writePrivateFile(join(agentHomeDirectory, "config.json"), `${JSON.stringify({ autoUpdate: false, trustedFolders: [], diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v4-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v4-identity.json new file mode 100644 index 00000000000..a26cb7f3f09 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v4-identity.json @@ -0,0 +1,16 @@ +{ + "commandDigest": "sha256:e7f8416b1119895c8c79307b95fc0e46312cc4821abee2a691f26c3d67f282af", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 4, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "a64405199b381688ffd72fdbb88396e423394cb407813d7b6f1db43bae9c3933", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "bb3dcf14972e82f6578c8250540cfbc6ec2ad953bea5d4f10d28c8b461011f3f", + "agentFilesBinding": "registered-runtime-context-v1", + "systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-before-admission:v1" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-v4-native-instruction-delivery-2026-09-29.json b/packages/paperclip-runner/test/fixtures/copilot-v4-native-instruction-delivery-2026-09-29.json new file mode 100644 index 00000000000..934a63c35cc --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-v4-native-instruction-delivery-2026-09-29.json @@ -0,0 +1,61 @@ +{ + "schema": "paperclip.copilot-v4-native-instruction-delivery.v1", + "date": "2026-09-29", + "candidateBaseRevision": "751f59f26ce401b0737f141167ffd895180e07fa", + "profileIdentity": { + "commandDigest": "sha256:e7f8416b1119895c8c79307b95fc0e46312cc4821abee2a691f26c3d67f282af", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 4, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "a64405199b381688ffd72fdbb88396e423394cb407813d7b6f1db43bae9c3933", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "bb3dcf14972e82f6578c8250540cfbc6ec2ad953bea5d4f10d28c8b461011f3f", + "agentFilesBinding": "registered-runtime-context-v1", + "systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-before-admission:v1" + } + }, + "sourceSha256": { + "packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts": "897669e02875b7b53cabcbb95f86fbf7be3277c325e8a9bf5a7b4d0bcdffd389", + "packages/paperclip-runner/src/drivers/acpx/copilot-profile.ts": "0ff47a0394b2c72bdc1bdd318b684b16efc3f2fbf648002da139d6fb9c2bf992", + "packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts": "38a91b69773e1faab4cbe060e1ae991f8fd1233a7e241bb149063a2bd0dfb5da", + "packages/paperclip-runner/src/drivers/acpx/runtime-host.ts": "bffd96aa6467f9b7e80de7e0699b935f488f203530c6217045ef44d094ddd61c" + }, + "nativeVersion": "1.0.88", + "nativeBinarySha256": "a9ff8babb10b7e443182ae96a8bc50a9c826ef1c773e1344c396eb5bf7f512c3", + "actualProductionSandboxWriter": true, + "providerMode": "agent", + "allowAll": "off", + "fixtureMechanism": "env -i; synthetic credential; COPILOT_OFFLINE=true; exactly two requested turns to loopback OpenAI fixture. Imports actual candidate TypeScript runtime host/sandbox/adapter via tsx, with already verified patched ACPX dependency from frozen 3d0 archive. No manual instruction file write. Test-only offline model metadata injection remains; not authenticated model qualification or final frozen-pack proof.", + "loopbackModelResponses": 2, + "paidProviderCalls": 0, + "nativeLoadObserved": true, + "staleDirectoryFailurePreserved": "ENOENT", + "continuationResult": { + "status": "completed", + "stopReason": "end_turn" + }, + "modelRequestProjection": [ + { + "request": 1, + "paperclipPromptInSystem": true, + "oldRegisteredRootInSystem": true, + "currentRegisteredRootInSystem": false, + "originalCustomEntryInSystem": true, + "updatedCustomEntryInSystem": false + }, + { + "request": 2, + "paperclipPromptInSystem": true, + "oldRegisteredRootInSystem": false, + "currentRegisteredRootInSystem": true, + "originalCustomEntryInSystem": false, + "updatedCustomEntryInSystem": true + } + ], + "rawPrivateEvidenceSha256": "02163cfff37ac96ec1775ae716122f32f1da960d0ba36be2dd94051615d1e4b4", + "probeScriptSha256": "b8434b98e0659efeab82dd5885740e2c85c6e1669edb09c74c3a9d41bb25bc13" +}