Preserve split UTF-8 Copilot metadata and qualify profile v32

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-08 10:18:34 -05:00
1 parent 2011dbe60d
commit d09804d201
18 files changed
+57 -27

No files matched your search

+2 -2
View File
@@ -32,12 +32,12 @@
"requiresProviderPolicy": true
},
"copilot": {
"agentProfileVersion": 31,
"agentProfileVersion": 32,
"agentServerPackage": "@github/copilot",
"agentServerVersion": "1.0.88",
"agentRuntimePackage": null,
"agentRuntimeVersion": null,
"commandDigest": "sha256:4a22e4c50fd213c79cd02fd3c5369aef2256ac719762c62f5b70c691cd47e76b",
"commandDigest": "sha256:3dcc8de3034e32001b0a819a614beca345d314266a70a09e0050a9f5f58275fc",
"requiresProviderPolicy": true
},
"claude": {
@@ -3620,7 +3620,7 @@ mod tests {
descriptor_value["agentRuntimePackage"] = Value::Null;
descriptor_value["agentRuntimeVersion"] = Value::Null;
descriptor_value["commandDigest"] = serde_json::from_str::<Value>(include_str!(
"../../../../test/fixtures/copilot-profile-v31-identity.json"
"../../../../test/fixtures/copilot-profile-v32-identity.json"
))
.unwrap()["commandDigest"]
.clone();
@@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
agent_runtime_package: None,
agent_runtime_version: None,
command_digest:
"sha256:4a22e4c50fd213c79cd02fd3c5369aef2256ac719762c62f5b70c691cd47e76b",
"sha256:3dcc8de3034e32001b0a819a614beca345d314266a70a09e0050a9f5f58275fc",
requires_provider_policy: true,
},
"claude" => AcpxReleaseProfile {
@@ -1029,7 +1029,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-v31-identity.json"
"../../../../test/fixtures/copilot-profile-v32-identity.json"
))
.unwrap();
let digest = identity["commandDigest"].as_str().unwrap();
@@ -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:4a22e4c50fd213c79cd02fd3c5369aef2256ac719762c62f5b70c691cd47e76b";
const PROFILE_DIGEST = "sha256:3dcc8de3034e32001b0a819a614beca345d314266a70a09e0050a9f5f58275fc";
/** 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 } = {}) {
@@ -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-v31-identity.json");
const fixture = resolve(root, "test/fixtures/copilot-profile-v32-identity.json");
const historical = JSON.parse(readFileSync(resolve(root, "test/fixtures/copilot-profile-v12-identity.json"), "utf8"));
const declaration = { ...historical.declaration, agentProfileVersion: 31 };
const declaration = { ...historical.declaration, agentProfileVersion: 32 };
delete declaration.permissionIdentitySourceSha256;
const sources = {
displayEventSourceSha256: "src/drivers/acpx/copilot-events.ts",
@@ -1,3 +1,4 @@
import { StringDecoder } from "node:string_decoder";
// Metadata only. Supply an explicit token; this closed protocol client never prompts.
// Usage: node discover-copilot-acp.mjs <verified-pack> <safe-output.json> [exact-model-id]
import assert from "node:assert/strict";
@@ -24,6 +25,7 @@ export async function withDiscoveryWorkspace(install, callback, filesystem = { m
}
export function createDiscoveryRpc(child, { deadlineMs = 25_000, maxBytes = 2_097_152 } = {}) {
const decoder = new StringDecoder("utf8");
const pending = new Map(), observedMethods = {};
let nextId = 0, buffer = "", bytes = 0, failure;
function fail(code) {
@@ -39,7 +41,7 @@ export function createDiscoveryRpc(child, { deadlineMs = 25_000, maxBytes = 2_09
child.stdout.on("data", chunk => {
bytes += chunk.length;
if (bytes > maxBytes) { fatal("provider_wire_limit"); return; }
buffer += chunk.toString();
buffer += decoder.write(chunk);
while (buffer.includes("\n")) {
const end = buffer.indexOf("\n"), line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
if (!line.trim()) continue;
@@ -38,3 +38,12 @@ test("closed discovery protocol preserves numeric zero and rejects prompts", asy
assert.deepEqual(await pending, { protocolVersion: 1 });
assert.throws(() => rpc.request("session/prompt", {}), /cannot send/); rpc.close();
});
test("discovery preserves multibyte model names when every byte arrives separately", async () => {
const child = childFixture(), rpc = createDiscoveryRpc(child);
const result = { models: { availableModels: [{ modelId: "gpt-5.6-luna", name: "東京 🚀 café" }] } };
const pending = rpc.request("session/new", {});
const wire = Buffer.from(`${JSON.stringify({ jsonrpc: "2.0", id: 0, result })}\n`);
for (const byte of wire) child.stdout.emit("data", Buffer.from([byte]));
assert.deepEqual(await pending, result); rpc.close();
});
@@ -1,3 +1,4 @@
import { StringDecoder } from "node:string_decoder";
// Real-service qualification only. One explicit scenario sends one ACP prompt.
// Usage: node qualify-copilot-acp.mjs <verified-pack> <private-output.json> <deny-write|detached-shell>
// Bind only COPILOT_GITHUB_TOKEN. Do not run without a reconciled billing reservation.
@@ -75,6 +76,7 @@ export async function runProbe(packInput, output, scenario, token) {
backgroundCompletionObservedBeforeTerminal: false, cleanupComplete: false };
let child, exitPromise, sessionId, promptId, nextId = 0, buffer = "", bytes = 0, terminalSeen = false;
let outerTimer, markerTimer;
const decoder = new StringDecoder("utf8");
const pending = new Map();
const env = { PATH: "/usr/bin:/bin", COPILOT_GITHUB_TOKEN: token, COPILOT_AUTO_UPDATE: "false", COPILOT_ALLOW_ALL: "false", NO_COLOR: "1" };
async function sampleMarker(phase) {
@@ -115,7 +117,7 @@ export async function runProbe(packInput, output, scenario, token) {
child.stdout.on("data", chunk => {
bytes += chunk.length;
if (bytes > 8_388_608) { failPending("wire_limit"); child.kill("SIGTERM"); return; }
buffer += chunk.toString();
buffer += decoder.write(chunk);
while (buffer.includes("\n")) {
const index = buffer.indexOf("\n"), line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
if (!line.trim()) continue;
@@ -69,13 +69,19 @@ test("full credential-free protocol fixture preserves numeric-zero denial and re
await writeFile(join(moduleRoot, "copilot-events.js"), 'export const COPILOT_ACP_CLIENT_CAPABILITIES = {};');
const childScript = join(root, "child.mjs");
await writeFile(childScript, `import { createInterface } from 'node:readline';
const send = m => process.stdout.write(JSON.stringify({jsonrpc:'2.0', ...m})+'\\n');
const send = m => {
const wire=Buffer.from(JSON.stringify({jsonrpc:'2.0', ...m})+'\\n');
const unicode=wire.indexOf(Buffer.from('🚀'));
if(unicode<0){process.stdout.write(wire);return;}
process.stdout.write(wire.subarray(0,unicode+2));
setTimeout(()=>process.stdout.write(wire.subarray(unicode+2)),50);
};
let promptId;
createInterface({input:process.stdin}).on('line', line => {
const m=JSON.parse(line);
if(!m.method){if(m.id!==0 || m.result?.outcome?.optionId!=='native-deny-0') process.exit(8); send({id:promptId,result:{stopReason:'end_turn'}});return;}
let result={};
if(m.method==='initialize')result={agentInfo:{version:'1.0.88'},agentCapabilities:{}};
if(m.method==='initialize')result={agentInfo:{version:'1.0.88'},agentCapabilities:{label:'東京 🚀 café'}};
if(m.method==='session/new')result={sessionId:'fixture-session',models:{availableModels:[{modelId:'gpt-5.6-luna'}]}};
if(m.method==='session/set_config_option')result={configOptions:[{id:'model',currentValue:'gpt-5.6-luna'}]};
if(m.method==='session/prompt'){promptId=m.id;send({id:0,method:'session/request_permission',params:{sessionId:'fixture-session',toolCall:{kind:'edit',rawInput:{fileName:process.cwd()+'/qualification-marker.txt'}},options:${JSON.stringify(options)}}});return;}
@@ -86,6 +92,7 @@ createInterface({input:process.stdin}).on('line', line => {
const result = await runProbe(root, output, "deny-write", "fixture-token-never-used-for-network");
const evidence = JSON.parse(await readFile(output, "utf8"));
assert.equal(result.passed, true);
assert.deepEqual(evidence.agentCapabilities, { label: "東京 🚀 café" });
assert.equal(evidence.permissions[0].requestId, 0);
assert.deepEqual(evidence.permissions[0].outcome, { outcome: "selected", optionId: "native-deny-0" });
assert.equal(evidence.cleanupComplete, true);
@@ -391,7 +391,7 @@ describe("NativeExecutionInputV1", () => {
})).toThrow("does not match");
});
it.each([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31] as const)("decodes closed Copilot profile history at version %s without admitting a future version", (agentProfileVersion) => {
it.each([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32] as const)("decodes closed Copilot profile history at version %s without admitting a future version", (agentProfileVersion) => {
const { qualificationModel: _qualificationModel, reportedModelId: _reportedModelId,
permissionPolicy, qualificationStatus: _qualificationStatus, modelPolicy: _modelPolicy,
...snapshot } = QUALIFIED_ACPX_PROFILES.copilot;
@@ -403,7 +403,7 @@ describe("NativeExecutionInputV1", () => {
const parsed = parseNativeExecutionInput(value);
expect(parsed.provider).toEqual(provider);
expect(parseNativeExecutionInput(parsed)).toEqual(parsed);
for (const unsupportedVersion of [0, 32, 1.5, "31", null]) {
for (const unsupportedVersion of [0, 33, 1.5, "32", null]) {
expect(() => parseNativeExecutionInput({
...value, provider: { ...provider, profile: { ...provider.profile, agentProfileVersion: unsupportedVersion } },
})).toThrow("qualified ACPX v1 profile");
@@ -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-v31-identity.json", import.meta.url), "utf8"));
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v32-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);
@@ -101,7 +101,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-v31-identity.json", import.meta.url), "utf8"));
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v32-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);
@@ -48,12 +48,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = {
"protocolVersion": 1,
"acpxVersion": "0.13.1",
"agent": "copilot",
"agentProfileVersion": 31,
"agentProfileVersion": 32,
"agentServerPackage": "@github/copilot",
"agentServerVersion": "1.0.88",
"agentRuntimePackage": null,
"agentRuntimeVersion": null,
"commandDigest": "sha256:4a22e4c50fd213c79cd02fd3c5369aef2256ac719762c62f5b70c691cd47e76b",
"commandDigest": "sha256:3dcc8de3034e32001b0a819a614beca345d314266a70a09e0050a9f5f58275fc",
"permissionPolicy": "interactive"
},
"claude": {
@@ -10,10 +10,10 @@ describe("historical ACPX profile decoding", () => {
}
});
it("decodes retained Copilot revisions and the current profile without widening other providers", () => {
for (const version of [1, 5, 6, 12, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]) {
for (const version of [1, 5, 6, 12, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32]) {
expect(isSupportedAcpxProfileVersion("copilot", version)).toBe(true);
}
expect(isSupportedAcpxProfileVersion("copilot", 32)).toBe(false);
expect(isSupportedAcpxProfileVersion("copilot", 33)).toBe(false);
for (const agent of ["cursor", "pi", "claude", "codex", "grok"]) {
expect(isSupportedAcpxProfileVersion(agent, 23)).toBe(false);
expect(isSupportedAcpxProfileVersion(agent, 24)).toBe(false);
@@ -1,9 +1,9 @@
/** Decodable historical profile revisions, not permission to launch them.
* Exact current profile/package/digest admission is checked separately. */
export type AcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31;
export type AcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32;
const historicalVersions: Readonly<Record<string, readonly AcpxProfileVersion[]>> = {
pi: [1, 2, 3, 4, 5], claude: [1, 2, 3, 4, 5], codex: [1, 2, 3, 4, 5],
grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31],
grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32],
cursor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14],
};
@@ -48,6 +48,14 @@ describe("Copilot metadata boundary", () => {
expect(await probeCopilotMetadata("token", "missing")).toMatchObject({ status: "failed", code: "COPILOT_MODEL_UNAVAILABLE", promptSent: false });
expect(methods).toEqual(["initialize", "session/new"]); expect(close).toHaveBeenCalledOnce();
});
it("preserves model labels split inside every UTF-8 character across transport chunks", async () => {
const child = fakeChild(() => undefined), rpc = createMetadataRpc(child);
const result = { models: { availableModels: [{ modelId: "gpt-5.6-luna", name: "東京 🚀 café" }] } };
const response = rpc.request("session/new", {});
const wire = Buffer.from(`${JSON.stringify({ jsonrpc: "2.0", id: 0, result })}\n`);
for (const byte of wire) child.stdout.emit("data", Buffer.from([byte]));
await expect(response).resolves.toEqual(result);
});
it("cannot dispatch semantic or prompt methods", () => {
const child = fakeChild(() => undefined), rpc = createMetadataRpc(child);
expect(() => rpc.request("session/prompt", {})).toThrow("cannot send");
@@ -1,3 +1,4 @@
import { StringDecoder } from "node:string_decoder";
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
@@ -90,6 +91,7 @@ export async function probeCopilotMetadata(token: string, model?: string): Promi
}
export function createMetadataRpc(child: ChildProcessWithoutNullStreams) {
const decoder = new StringDecoder("utf8");
const pending = new Map<number, { resolve(value: unknown): void; reject(error: Error): void }>();
let nextId = 0, bytes = 0, buffer = "", failure: Error | undefined;
const fail = (error: Error) => { failure ??= error; for (const call of pending.values()) call.reject(failure); pending.clear(); };
@@ -100,7 +102,7 @@ export function createMetadataRpc(child: ChildProcessWithoutNullStreams) {
child.stdout.on("data", (chunk: Buffer) => {
bytes += chunk.length;
if (bytes > 2 * 1024 * 1024) { fail(new Error("Copilot metadata exceeded its bound")); child.kill("SIGTERM"); return; }
buffer += chunk.toString();
buffer += decoder.write(chunk);
while (buffer.includes("\n")) {
const end = buffer.indexOf("\n"), line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
if (!line.trim()) continue;
@@ -1,9 +1,9 @@
{
"commandDigest": "sha256:4a22e4c50fd213c79cd02fd3c5369aef2256ac719762c62f5b70c691cd47e76b",
"commandDigest": "sha256:3dcc8de3034e32001b0a819a614beca345d314266a70a09e0050a9f5f58275fc",
"declaration": {
"schema": "paperclip.acpx_profile_declaration.v1",
"agent": "copilot",
"agentProfileVersion": 31,
"agentProfileVersion": 32,
"acpxVersion": "0.13.1",
"agentServerVersion": "1.0.88",
"protocolPolicyRevision": "copilot-agent-manual-v1",
@@ -62,14 +62,14 @@
"runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211",
"guardedInstallationSourceSha256": "8afd25df307772e484e179e58bf54ce1510fe92be694482111d589f9f0ae6e77",
"nativeExecutionSourceSha256": "4cdd702c233bf0d086ff2aeea8f02c6db8817ba0c0bcb5e64e355ee7a1c2dd77",
"profileCompatibilitySourceSha256": "ef78b62e804473f7c1c9f5e119b4755e774f1ba1cdb7b01609b74392af2547e6",
"profileCompatibilitySourceSha256": "fe521686dbd9884eec96ba0963ec00b6b58f4d891aa0292e0dc18b0d84770646",
"installationSourceSha256": "9934653d6983905930099b1691b1f0c05461e04e950bb27dd0f92e64fc8e28e1",
"assetOwnershipSourceSha256": "43005b7e896e0546c1f44cbe4ff3f34ad6d71f275f4b9a8113f73a71e77930c4",
"assetInstallerSourceSha256": "d3bd9514e5920d0cc3567aa50487fb534c57a6f66b8da0d3f22c5f6e499c841f",
"credentialBindingSourceSha256": "d5c200bea2e8ab730eb56e32ce2070c8a1b5407e5907108640f76a463d9b9c9c",
"sidecarErrorSourceSha256": "23f3dbdc1ce488c690d218a854c820f4b4f3edd822bad574d67707314f8c512a",
"profileSourceSha256": "baf0b64371fada6c92331f9f88e915292b2761c029fd86452f6de32e26dc7fd6",
"metadataProbeSourceSha256": "e8bdefca57b9678d2b5d99b0bc604301d12993b9c9cc4ecb4b5d3df0b83cec3f",
"metadataProbeSourceSha256": "826e74747a89b83a6f546ee1355de64ae5265eb920b50ae498a41dd561a493ee",
"permissionToolIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1",
"runtimeHostSourceSha256": "83a5ae07c19681ab195f76363661c2381494b243343270ba363d4db2088932a5",
"runtimeAdapterSourceSha256": "a1a8784f5883d2c93df6e3695d5499b83e226efb4f26e558639c4e7dbcc2d67a",