fix(runner): tighten Copilot discovery evidence and cleanup

Reject auto as exact model evidence, release native leases on early workspace failure, and reject pending metadata requests immediately when the provider exits or sends malformed output. Extend early cleanup to the real-service probe.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-28 15:49:16 -05:00
1 parent b371ca7a32
commit 19ca0f558d
3 files changed
+174 -42

No files matched your search

@@ -1,41 +1,130 @@
// Metadata only: initialize, create a disposable session, select an advertised model, then close.
// Usage: node discover-copilot-acp.mjs <verified-provider-pack> <safe-output.json> [exact-model-id]
// Supply only an explicitly bound COPILOT_GITHUB_TOKEN. This script never sends a prompt.
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
const pack=await realpath(process.argv[2]);
const output=process.argv[3];
const model=process.argv[4];
const token=process.env.COPILOT_GITHUB_TOKEN;
assert.equal(typeof token,'string');assert.ok(token.trim());
const manifest=JSON.parse(await readFile(join(pack,'provider-pack.json'),'utf8'));
process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT=pack;
process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST=join(pack,'package.json');
const {verifyAcpxProfileInstallation,assertAcpxProfileEnvironment}=await import(pathToFileURL(join(pack,'dist/drivers/acpx/profile-installation.js')));
const {resolveQualifiedAcpxProfile}=await import(pathToFileURL(join(pack,'dist/drivers/acpx/qualified-profiles.js')));
const {COPILOT_ACP_CLIENT_CAPABILITIES}=await import(pathToFileURL(join(pack,'dist/drivers/acpx/copilot-events.js')));
assertAcpxProfileEnvironment('copilot',{COPILOT_GITHUB_TOKEN:token});
// The verified profile resolves the fixed binary only. session/new discovers the
// native catalog; no prompt or model inference is permitted in this script.
const install=await verifyAcpxProfileInstallation(resolveQualifiedAcpxProfile('copilot',model??'discovery-unselected'));
const lease=await install.openCommand();
const root=await mkdtemp('/tmp/paperclip-copilot-auth-discovery-');
const env={PATH:'/usr/bin:/bin',COPILOT_GITHUB_TOKEN:token,COPILOT_AUTO_UPDATE:'false',COPILOT_ALLOW_ALL:'false',NO_COLOR:'1'};
for(const key of ['HOME','XDG_CONFIG_HOME','XDG_DATA_HOME','XDG_CACHE_HOME','COPILOT_HOME','COPILOT_CACHE_HOME']){env[key]=join(root,key.toLowerCase());await mkdir(env[key],{mode:0o700});}
await writeFile(join(env.COPILOT_HOME,'config.json'),JSON.stringify({autoUpdate:false,trustedFolders:[],disableAllHooks:true,memory:false,ide:{autoConnect:false}}),{mode:0o600});
let child,exitPromise;const pending=new Map();let nextId=0,buffer='',bytes=0;const observedMethods={};
function request(method,params){assert.ok(['initialize','session/new','session/set_model','session/set_config_option','session/close'].includes(method));const id=nextId++;return new Promise((resolve,reject)=>{const timer=setTimeout(()=>{pending.delete(id);reject(new Error('request_deadline:'+method));},25000);pending.set(id,{resolve,reject,timer});child.stdin.write(JSON.stringify({jsonrpc:'2.0',id,method,params})+'\n');});}
try{
child=lease.spawn([],{cwd:root,env,stdio:'pipe'});exitPromise=new Promise(resolve=>child.once('exit',(code,signal)=>resolve({code,signal})));
child.stdout.on('data',chunk=>{bytes+=chunk.length;if(bytes>2097152){child.kill('SIGTERM');return;}buffer+=chunk.toString();while(buffer.includes('\n')){const end=buffer.indexOf('\n');const line=buffer.slice(0,end);buffer=buffer.slice(end+1);if(!line.trim())continue;let msg;try{msg=JSON.parse(line);}catch{child.kill('SIGTERM');return;}if(msg.method){observedMethods[msg.method]=(observedMethods[msg.method]??0)+1;if(msg.id!==undefined)child.stdin.write(JSON.stringify({jsonrpc:'2.0',id:msg.id,error:{code:-32601,message:'Discovery does not support inbound methods'}})+'\n');continue;}const item=pending.get(msg.id);if(item){clearTimeout(item.timer);pending.delete(msg.id);msg.error?item.reject(new Error('provider_rpc_error:'+String(msg.error.code))):item.resolve(msg.result);}}});
child.stderr.on('data',()=>{});child.once('error',()=>{for(const item of pending.values())item.reject(new Error('provider_spawn_failure'));});
const initialized=await request('initialize',{protocolVersion:1,clientInfo:{name:'paperclip-authenticated-model-discovery',version:'1'},clientCapabilities:COPILOT_ACP_CLIENT_CAPABILITIES});
const opened=await request('session/new',{cwd:root,mcpServers:[]});
const safe={schema:'paperclip.copilot-authenticated-discovery/v1',observedAt:new Date().toISOString(),harnessVersion:initialized.agentInfo?.version,sourceRevision:manifest.payload.runnerSourceRevision,providerPackDigest:manifest.digest,profileDigest:install.commandDigest,agentCapabilities:initialized.agentCapabilities,models:opened.models??null,configOptions:opened.configOptions??[],promptSent:false,promptRequestsSent:0,inferenceVerified:false,qualification:'pending; metadata discovery only',observedMethods};
if(model){const advertised=opened.models?.availableModels??[];assert.ok(advertised.some(value=>value.modelId===model),'requested model must be advertised');const selected=await request('session/set_model',{sessionId:opened.sessionId,modelId:model});const configured=await request('session/set_config_option',{sessionId:opened.sessionId,configId:'model',value:model});assert.equal(configured.configOptions?.find(option=>option.id==='model')?.currentValue,model);safe.modelSelection={requestedModel:model,succeeded:true,response:selected,configEcho:configured};}
try{await request('session/close',{sessionId:opened.sessionId});safe.sessionClosed=true;}catch{safe.sessionClosed=false;}
const text=JSON.stringify(safe,null,2)+'\n';assert.ok(!text.includes(token));assert.ok(!text.includes(opened.sessionId));assert.ok(!text.includes(root));await writeFile(output,text,{mode:0o600});console.log(JSON.stringify({output,sha256:createHash('sha256').update(text).digest('hex'),harnessVersion:safe.harnessVersion,modelCount:safe.models?.availableModels?.length??0,configOptionIds:safe.configOptions.map(value=>value.id),modelIds:safe.models?.availableModels?.map(value=>value.modelId)??[],promptSent:false}));
}finally{for(const item of pending.values())clearTimeout(item.timer);pending.clear();if(child&&child.exitCode===null&&child.signalCode===null){child.stdin.end();const timer=setTimeout(()=>child.kill('SIGTERM'),1000);const kill=setTimeout(()=>child.kill('SIGKILL'),5000);await exitPromise;clearTimeout(timer);clearTimeout(kill);}await lease.close();await rm(root,{recursive:true,force:true});}
// 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";
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
const discoveryMethods = new Set(["initialize", "session/new", "session/set_model", "session/set_config_option", "session/close"]);
export function validateDiscoveryModel(model) {
assert.ok(model === undefined || (typeof model === "string" && model.trim() && model.trim().toLowerCase() !== "auto"), "model must be an exact model ID, not auto");
}
export async function withDiscoveryWorkspace(install, callback, filesystem = { mkdtemp, rm }) {
const lease = await install.openCommand();
let root;
try {
root = await filesystem.mkdtemp("/tmp/paperclip-copilot-auth-discovery-");
return await callback(lease, root);
} finally {
try { await lease.close(); }
finally { if (root) await filesystem.rm(root, { recursive: true, force: true }); }
}
}
export function createDiscoveryRpc(child, { deadlineMs = 25_000, maxBytes = 2_097_152 } = {}) {
const pending = new Map(), observedMethods = {};
let nextId = 0, buffer = "", bytes = 0, failure;
function fail(code) {
failure ??= code;
for (const item of pending.values()) { clearTimeout(item.timer); item.reject(new Error(failure)); }
pending.clear();
}
function fatal(code) { fail(code); child.kill("SIGTERM"); }
child.once("close", () => fail("provider_exited"));
child.once("error", () => fail("provider_spawn_failure"));
child.stdin.on("error", () => fail("provider_stdin_failure"));
child.stderr.on("data", () => {});
child.stdout.on("data", chunk => {
bytes += chunk.length;
if (bytes > maxBytes) { fatal("provider_wire_limit"); return; }
buffer += chunk.toString();
while (buffer.includes("\n")) {
const end = buffer.indexOf("\n"), line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
if (!line.trim()) continue;
let message;
try { message = JSON.parse(line); } catch { fatal("provider_malformed_json"); return; }
if (!message || typeof message !== "object" || Array.isArray(message)) { fatal("provider_malformed_message"); return; }
if (message.method) {
observedMethods[message.method] = (observedMethods[message.method] ?? 0) + 1;
if (message.id !== undefined) child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Discovery does not support inbound methods" } })}\n`);
continue;
}
const item = pending.get(message.id);
if (item) { clearTimeout(item.timer); pending.delete(message.id); message.error ? item.reject(new Error(`provider_rpc_error:${message.error.code}`)) : item.resolve(message.result); }
}
});
return {
observedMethods,
request(method, params) {
assert.ok(discoveryMethods.has(method), "metadata discovery cannot send this method");
if (failure) return Promise.reject(new Error(failure));
const id = nextId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`request_deadline:${method}`)); }, deadlineMs);
pending.set(id, { resolve, reject, timer });
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
});
},
close() { fail("discovery_closed"); },
};
}
export async function discoverCopilot(packInput, output, model, token) {
validateDiscoveryModel(model);
assert.ok(typeof token === "string" && token.trim() && !token.includes("\0"), "explicit Copilot token required");
const pack = await realpath(packInput);
const manifest = JSON.parse(await readFile(join(pack, "provider-pack.json"), "utf8"));
process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT = pack;
process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST = join(pack, "package.json");
const { verifyAcpxProfileInstallation, assertAcpxProfileEnvironment } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/profile-installation.js")));
const { resolveQualifiedAcpxProfile } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/qualified-profiles.js")));
const { COPILOT_ACP_CLIENT_CAPABILITIES } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/copilot-events.js")));
assertAcpxProfileEnvironment("copilot", { COPILOT_GITHUB_TOKEN: token });
const install = await verifyAcpxProfileInstallation(resolveQualifiedAcpxProfile("copilot", model ?? "discovery-unselected"));
return withDiscoveryWorkspace(install, async (lease, root) => {
const env = { PATH: "/usr/bin:/bin", COPILOT_GITHUB_TOKEN: token, COPILOT_AUTO_UPDATE: "false", COPILOT_ALLOW_ALL: "false", NO_COLOR: "1" };
for (const key of ["HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "COPILOT_HOME", "COPILOT_CACHE_HOME"]) {
env[key] = join(root, key.toLowerCase()); await mkdir(env[key], { mode: 0o700 });
}
await writeFile(join(env.COPILOT_HOME, "config.json"), JSON.stringify({ autoUpdate: false, trustedFolders: [], disableAllHooks: true, memory: false, ide: { autoConnect: false } }), { mode: 0o600 });
let child, exitPromise, rpc;
try {
child = lease.spawn([], { cwd: root, env, stdio: "pipe" });
exitPromise = new Promise(resolve => child.once("close", (code, signal) => resolve({ code, signal })));
rpc = createDiscoveryRpc(child);
const initialized = await rpc.request("initialize", { protocolVersion: 1, clientInfo: { name: "paperclip-authenticated-model-discovery", version: "1" }, clientCapabilities: COPILOT_ACP_CLIENT_CAPABILITIES });
const opened = await rpc.request("session/new", { cwd: root, mcpServers: [] });
const safe = { schema: "paperclip.copilot-authenticated-discovery/v1", observedAt: new Date().toISOString(), harnessVersion: initialized.agentInfo?.version,
sourceRevision: manifest.payload.runnerSourceRevision, providerPackDigest: manifest.digest, profileDigest: install.commandDigest,
agentCapabilities: initialized.agentCapabilities, models: opened.models ?? null, configOptions: opened.configOptions ?? [], promptSent: false,
promptRequestsSent: 0, inferenceVerified: false, qualification: "pending; metadata discovery only", observedMethods: rpc.observedMethods };
if (model) {
assert.ok(opened.models?.availableModels?.some(value => value.modelId === model), "requested exact model must be advertised");
const selected = await rpc.request("session/set_model", { sessionId: opened.sessionId, modelId: model });
const configured = await rpc.request("session/set_config_option", { sessionId: opened.sessionId, configId: "model", value: model });
assert.equal(configured.configOptions?.find(option => option.id === "model")?.currentValue, model);
safe.modelSelection = { requestedModel: model, succeeded: true, response: selected, configEcho: configured };
}
try { await rpc.request("session/close", { sessionId: opened.sessionId }); safe.sessionClosed = true; }
catch { safe.sessionClosed = false; }
const text = `${JSON.stringify(safe, null, 2)}\n`;
assert.ok(!text.includes(token)); assert.ok(!text.includes(opened.sessionId)); assert.ok(!text.includes(root));
await writeFile(output, text, { mode: 0o600 });
return { output, sha256: createHash("sha256").update(text).digest("hex"), harnessVersion: safe.harnessVersion, modelCount: safe.models?.availableModels?.length ?? 0,
configOptionIds: safe.configOptions.map(value => value.id), modelIds: safe.models?.availableModels?.map(value => value.modelId) ?? [], promptSent: false };
} finally {
rpc?.close();
if (child && child.exitCode === null && child.signalCode === null) {
child.stdin.end();
const term = setTimeout(() => child.kill("SIGTERM"), 1_000), kill = setTimeout(() => child.kill("SIGKILL"), 5_000);
try { await exitPromise; } finally { clearTimeout(term); clearTimeout(kill); }
}
}
});
}
if (process.argv[1] && pathToFileURL(await realpath(process.argv[1])).href === import.meta.url) {
console.log(JSON.stringify(await discoverCopilot(process.argv[2], process.argv[3], process.argv[4], process.env.COPILOT_GITHUB_TOKEN)));
}
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import test from "node:test";
import { createDiscoveryRpc, validateDiscoveryModel, withDiscoveryWorkspace } from "./discover-copilot-acp.mjs";
test("exact model evidence rejects auto before any provider operation", () => {
for (const model of ["auto", "AUTO", " auto ", ""]) assert.throws(() => validateDiscoveryModel(model), /exact model ID/);
validateDiscoveryModel("gpt-5.6-luna"); validateDiscoveryModel(undefined);
});
test("workspace failure always releases the acquired native command lease", async () => {
const closed = [], removed = [];
const install = { openCommand: async () => ({ close: async () => closed.push(true) }) };
await assert.rejects(withDiscoveryWorkspace(install, () => assert.fail(), { mkdtemp: async () => { throw new Error("mkdir_failed"); }, rm: async (...args) => removed.push(args) }), /mkdir_failed/);
assert.equal(closed.length, 1); assert.equal(removed.length, 0);
await assert.rejects(withDiscoveryWorkspace(install, async () => { throw new Error("config_write_failed"); }, { mkdtemp: async () => "/fixture/private", rm: async (...args) => removed.push(args) }), /config_write_failed/);
assert.equal(closed.length, 2); assert.deepEqual(removed, [["/fixture/private", { recursive: true, force: true }]]);
});
function childFixture() {
const child = new EventEmitter(); child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.stdin = new EventEmitter();
child.written = []; child.stdin.write = value => child.written.push(JSON.parse(value)); child.killed = [];
child.kill = signal => child.killed.push(signal); return child;
}
test("provider exit rejects pending discovery immediately with its actual failure", async () => {
const child = childFixture(), rpc = createDiscoveryRpc(child);
const pending = rpc.request("initialize", {}); child.emit("close", 1, null);
await assert.rejects(pending, /provider_exited/);
await assert.rejects(rpc.request("session/new", {}), /provider_exited/);
});
test("malformed provider output rejects pending requests instead of waiting for timeout", async () => {
const child = childFixture(), rpc = createDiscoveryRpc(child);
const pending = rpc.request("initialize", {}); child.stdout.emit("data", Buffer.from("not-json\n"));
await assert.rejects(pending, /provider_malformed_json/); assert.deepEqual(child.killed, ["SIGTERM"]);
});
test("closed discovery protocol preserves numeric zero and rejects prompts", async () => {
const child = childFixture(), rpc = createDiscoveryRpc(child);
const pending = rpc.request("initialize", {}); assert.equal(child.written[0].id, 0);
child.stdout.emit("data", Buffer.from('{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1}}\n'));
assert.deepEqual(await pending, { protocolVersion: 1 });
assert.throws(() => rpc.request("session/prompt", {}), /cannot send/); rpc.close();
});
@@ -47,7 +47,9 @@ export async function runProbe(packInput, output, scenario, token) {
assertAcpxProfileEnvironment("copilot", { COPILOT_GITHUB_TOKEN: token });
const install = await verifyAcpxProfileInstallation(resolveQualifiedAcpxProfile("copilot", model));
const lease = await install.openCommand();
const root = await mkdtemp("/tmp/paperclip-copilot-live-probe-");
let root;
try { root = await mkdtemp("/tmp/paperclip-copilot-live-probe-"); }
catch (error) { await lease.close(); throw error; }
const marker = join(root, "qualification-marker.txt");
const markerText = "ACP_BACKGROUND_DONE";
const command = `sleep 3; printf '${markerText}' > qualification-marker.txt`;
@@ -106,6 +108,7 @@ export async function runProbe(packInput, output, scenario, token) {
if (!line.trim()) continue;
let message;
try { message = JSON.parse(line); } catch { failPending("malformed_json"); child.kill("SIGTERM"); return; }
if (!message || typeof message !== "object" || Array.isArray(message)) { failPending("malformed_message"); child.kill("SIGTERM"); return; }
evidence.wire.push({ elapsedMs: elapsed(), message });
if (message.method) {
const params = message.params;