diff --git a/packages/paperclip-runner/scripts/discover-copilot-acp.mjs b/packages/paperclip-runner/scripts/discover-copilot-acp.mjs index b45b2d5f8d..dc0b1fc5fe 100644 --- a/packages/paperclip-runner/scripts/discover-copilot-acp.mjs +++ b/packages/paperclip-runner/scripts/discover-copilot-acp.mjs @@ -1,41 +1,130 @@ -// Metadata only: initialize, create a disposable session, select an advertised model, then close. -// Usage: node discover-copilot-acp.mjs [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 [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))); +} diff --git a/packages/paperclip-runner/scripts/discover-copilot-acp.test.mjs b/packages/paperclip-runner/scripts/discover-copilot-acp.test.mjs new file mode 100644 index 0000000000..d33b281700 --- /dev/null +++ b/packages/paperclip-runner/scripts/discover-copilot-acp.test.mjs @@ -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(); +}); diff --git a/packages/paperclip-runner/scripts/qualify-copilot-acp.mjs b/packages/paperclip-runner/scripts/qualify-copilot-acp.mjs index 038ad0b0e5..ca3982dc3c 100644 --- a/packages/paperclip-runner/scripts/qualify-copilot-acp.mjs +++ b/packages/paperclip-runner/scripts/qualify-copilot-acp.mjs @@ -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;