mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
fix(copilot): deliver native instructions with v4 session identity
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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"
|
||||
}
|
||||
}
|
||||
Vendored
+61
@@ -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"
|
||||
}
|
||||
Reference in new issue
Block a user