fix(copilot): deliver native instructions with v4 session identity

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-29 11:12:26 -05:00
1 parent 751f59f26c
commit 0f33a9080a
10 files changed
+140 -10

No files matched your search

@@ -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
@@ -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!("");
@@ -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 } = {}) {
@@ -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();
@@ -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.
@@ -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",
@@ -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);
@@ -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: [],
@@ -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"
}
}
@@ -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"
}