diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index 9a7d0c691a..3ed2ac9e03 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -73,7 +73,7 @@ "typecheck:rust": "cargo fmt --manifest-path runner/Cargo.toml --all -- --check && cargo check --manifest-path runner/Cargo.toml --locked --workspace", "typecheck:browser": "tsc -p tsconfig.browser.json --noEmit", "test": "pnpm run test:typescript && pnpm run test:rust", - "test:typescript": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs scripts/materialize-pi-binary.test.mjs scripts/portable-provider-shim.test.mjs scripts/provider-pack-layout.test.mjs scripts/build-provider-pack.test.mjs && vitest run", + "test:typescript": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs scripts/materialize-pi-binary.test.mjs scripts/portable-provider-shim.test.mjs scripts/provider-pack-layout.test.mjs scripts/build-provider-pack.test.mjs scripts/verify-pi-provider-launch.test.mjs && vitest run", "test:rust": "cargo test --release --manifest-path runner/Cargo.toml --locked --workspace", "test:codex": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --test codex_provider", "test:durable": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core durable::", diff --git a/packages/paperclip-runner/scripts/verify-pi-provider-launch.mjs b/packages/paperclip-runner/scripts/verify-pi-provider-launch.mjs index 88115619d1..8311b73163 100644 --- a/packages/paperclip-runner/scripts/verify-pi-provider-launch.mjs +++ b/packages/paperclip-runner/scripts/verify-pi-provider-launch.mjs @@ -20,6 +20,9 @@ const child = lease.spawn([], { cwd: root, detached: true, env: { // Makes the static OpenRouter model catalog selectable; never sends a prompt. OPENROUTER_API_KEY: "qualification-no-model-requests", } }); +// Register before requesting a session so even an early close is observed. +let childDidClose = false; +const childClosed = new Promise((resolveClose) => child.once("close", () => { childDidClose = true; resolveClose(); })); let buffer = "", stderr = ""; const pending = new Map(); child.stderr.on("data", (data) => { stderr = (stderr + data).slice(-4000); }); @@ -47,7 +50,24 @@ try { assert.equal(typeof session.result?.sessionId, "string"); console.log("Verified Pi ACP and pinned RPC runtime started successfully"); } finally { - try { process.kill(-child.pid, "SIGTERM"); } catch {} - await lease.close(); - await rm(root, { recursive: true, force: true }); + // Pi can still write its private home while handling SIGTERM. Keep the + // temporary directory until its pipes close, with a bounded group teardown. + let forceTimer, deadlineTimer; + const shutdownDeadline = new Promise((_, reject) => { + forceTimer = setTimeout(() => { + try { if (!childDidClose) process.kill(-child.pid, "SIGKILL"); } + catch (error) { if (error.code !== "ESRCH") reject(error); } + }, 5_000); + deadlineTimer = setTimeout(() => reject(new Error("Pi qualification process did not close after SIGKILL")), 10_000); + }); + try { + try { if (!childDidClose) process.kill(-child.pid, "SIGTERM"); } + catch (error) { if (error.code !== "ESRCH") throw error; } + await Promise.race([childClosed, shutdownDeadline]); + } finally { + clearTimeout(forceTimer); + clearTimeout(deadlineTimer); + await lease.close(); + } + await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } diff --git a/packages/paperclip-runner/scripts/verify-pi-provider-launch.test.mjs b/packages/paperclip-runner/scripts/verify-pi-provider-launch.test.mjs new file mode 100644 index 0000000000..3dc6a989f7 --- /dev/null +++ b/packages/paperclip-runner/scripts/verify-pi-provider-launch.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; +import {spawn} from 'node:child_process'; +import {mkdtemp,mkdir,writeFile,readFile,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join,basename,dirname} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const verifier=fileURLToPath(new URL('./verify-pi-provider-launch.mjs',import.meta.url)); +for(const ignoreTerm of [false,true]) test('Pi qualification waits for process closure before removing HOME (ignore SIGTERM='+ignoreTerm+')',async(t)=>{ + if(process.platform==='win32'){t.skip('The image verifier uses POSIX process groups');return;} + const root=await mkdtemp(join(tmpdir(),'pi-qualification-cleanup-test-')); + try{ + const pack=join(root,'pack'),modules=join(pack,'dist/drivers/acpx');await mkdir(modules,{recursive:true}); + await writeFile(join(pack,'package.json'),JSON.stringify({type:'module'})); + await writeFile(join(modules,'qualified-profiles.js'),'export const resolveQualifiedAcpxProfile=()=>({});'); + await writeFile(join(modules,'installation-integrity.js'),` +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import {writeFile} from 'node:fs/promises'; +export const createAcpxPackageJsonResolver=()=>({}); +export async function verifyQualifiedAcpxInstallation(){return {openCommand:async()=>{ + let child,closed=false; + return {spawn:(_args,options)=>{ + child=spawn(${JSON.stringify(process.execPath)},[${JSON.stringify(join(root,'provider.mjs'))}],{...options,stdio:['pipe','pipe','pipe']}); + child.once('close',()=>{closed=true;}); + return child; + },close:async()=>{assert(closed,'lease was closed before the provider completed its shutdown writes');await writeFile(${JSON.stringify(join(root,'lease-closed'))},String(child.signalCode??child.exitCode));}}; +}};} +`); + await writeFile(join(root,'provider.mjs'),` +import fs from 'node:fs';import readline from 'node:readline';import path from 'node:path'; +fs.writeFileSync(${JSON.stringify(join(root,'home-path'))},process.env.HOME); +readline.createInterface({input:process.stdin}).on('line',line=>{const request=JSON.parse(line);process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:request.id,result:request.method==='initialize'?{}:{sessionId:'fixture-session'}})+'\\n');}); +process.on('SIGTERM',()=>{if(${ignoreTerm})return;setTimeout(()=>{fs.mkdirSync(path.join(process.env.HOME,'.pi'),{recursive:true});fs.writeFileSync(path.join(process.env.HOME,'.pi','last-write'),'shutdown completed');fs.writeFileSync(${JSON.stringify(join(root,'shutdown-finished'))},'finished');process.exit(0);},150);}); +`); + const child=spawn(process.execPath,[verifier,pack],{stdio:['ignore','pipe','pipe']}); + let stdout='',stderr='';child.stdout.on('data',b=>stdout+=b);child.stderr.on('data',b=>stderr+=b); + const timer=setTimeout(()=>child.kill('SIGKILL'),15000); + let code;try{code=await new Promise((resolve,reject)=>{child.once('error',reject);child.once('close',resolve);});}finally{clearTimeout(timer);} + assert.equal(code,0,stderr);assert.match(stdout,/Verified Pi ACP/); + if(!ignoreTerm)assert.equal(await readFile(join(root,'shutdown-finished'),'utf8'),'finished'); + assert.equal(await readFile(join(root,'lease-closed'),'utf8'),ignoreTerm?'SIGKILL':'0'); + const home=await readFile(join(root,'home-path'),'utf8');await assert.rejects(readFile(join(home,'.pi','last-write')), {code:'ENOENT'}); + }finally{ + const home=await readFile(join(root,'home-path'),'utf8').catch(()=>null); + if(home){assert.equal(dirname(home),tmpdir());assert(basename(home).startsWith('pi-qualified-launch-'));await rm(home,{recursive:true,force:true});} + await rm(root,{recursive:true,force:true}); + } +});