Files
PaperClipAI/packages/paperclip-runner/scripts/qualify-copilot-acp.mjs
T
DottaandPaperclip d855e550ad Finish Copilot native runtime on current master (#15701)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Paperclip Runner controls provider processes, permissions and task
completion.
> - Copilot needs its pinned distribution, native message identities and
accepted semantic completion receipts.
> - The release must preserve saved-token access and task lifecycle
guarantees.
> - This pull request will keep the current completion and recovery
fixes while adding the Copilot runtime.
> - The benefit is a reviewable part of the Copilot production release.

## Linked Issues or Issue Description

Refs #14434. Supersedes the runtime work in #15560. This is the first PR
in the stack; #15703, #15705 and #15706 follow. Related alternative
adapter work: #15881. This change uses the shared Runner path.

## What Changed

- Pin Copilot CLI 1.0.88 and bind the integrated implementation as
profile45.
- Keep native instruction, permission, receipt, process ownership and
cancellation boundaries.
- Preserve current master completion continuation and remote
verification behavior. Other provider release identities stay unchanged.

## Verification

Final reviewed source
[`4f3f1af807d2ed21adabb04c2f27f4df9c4bc8d5`](https://github.com/paperclipai/paperclip/commit/4f3f1af807d2ed21adabb04c2f27f4df9c4bc8d5)
includes master `cde944ed75648adfb569e86133adc8b118b55dd6`. The combined
four-part PR tree matches it except for the CI-owned lockfile. Every
prior public PR head remains an ancestor. Released master migrations
remain unchanged; Copilot adds migration361. No unrelated provider is
enabled.

Copilot CLI1.0.88 and profile47 retain digest
`sha256:55044aa235e88b3fe36ec8a556ee1e3382590870aab907e4462ef7d64af49cea`.
The pending qualification marker is removed. The [qualification
record](https://github.com/paperclipai/paperclip/blob/4f3f1af807d2ed21adabb04c2f27f4df9c4bc8d5/doc/plans/2026-10-20-copilot-qualification-v47.json)
reports **20/20 Product cells,154 assertions;7/7 protocol cases,46
assertions** against frozen installed application
`c2616370800f5f6df45246b0890ba35a7cd2faab`. Both environments use the
exact authenticated model `gpt-5.6-luna`. Every passing Product case
confirms cleanup. Automatic behavior retries are disabled. Original
failed attempts remain recorded.

The ordinary installed remote journey passed saved-token selection,
authenticated model discovery, normal UI save/reopen, metadata-only
Test, task completion, accepted receipt and an exact19-byte browser
download. An open model catalog caused no new discovery requests
during117seconds. Actual installed runtime smokes passed on macOS ARM64,
macOS x64 under Rosetta and native Linux x64 on Daytona. Raw token
browser entry was not exercised; the encrypted connection was created
through the normal API.

The original Daytona file attempt timed out during delayed semantic
dispatch. Its precise stalled layer remains unresolved. Independent
ordinary delivery and the other19 cells passed before one instrumented
recheck passed8/8 with unchanged source,prompt,assertions and deadlines.
No causal latency repair is claimed.

After current-master reconciliation,129 connection tests,59 runner
tests,72 shared eval tests and88 recovery/workspace tests pass.
Migration generation/safety, unchanged profile identity and UI token
gates pass. Prior required CI was green. Fresh exact-head required CI
and E2E are green for all four updated heads. Each has a fresh
Greptile5/5 review and zero unresolved review threads. The [final
normal-package cloud
build](https://github.com/paperclipai/paperclip/actions/runs/39414239704)
is running. Recursive typecheck,full CI test shards,build and all8
browser shards pass. These checks cover the final source; no additional
local full-suite pass is claimed.

The [frozen image
build](https://github.com/paperclipai/paperclip/actions/runs/39334401638)
and [original normal package
build](https://github.com/paperclipai/paperclip/actions/runs/39334407471)
passed. The exact image is published and independently retrieved
anonymously. No separate full-image scan is claimed. All18 original
normal archives were scanned with zero selected credentials. Docker
builds run in the cloud. Provider USD remains unknown and
GitHub-attributed.

**Qualification is complete on the recorded frozen build. Normal
protected merge, automatic master canary publication and final
shipped-package local/Daytona canaries remain. No production deployment
is claimed.**

## Risks

Shared Runner behavior can affect active tasks. Exact profile admission
rejects stale runtime identities. The final campaign and installed
canaries remain release gates.

## Model Used

OpenAI Codex, based on GPT-6. The exact serving model ID and context
window are not exposed in this task. Used for code changes, review and
tool-assisted verification.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-20 06:31:47 -05:00

206 lines
15 KiB
JavaScript

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.
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
export function permissionDecision(scenario, params, sessionId, command) {
const options = Array.isArray(params?.options) ? params.options : [];
const exactSession = typeof sessionId === "string" && params?.sessionId === sessionId;
const exactCommand = params?.toolCall?.rawInput?.command === command;
const kind = exactSession && scenario === "detached-shell" && exactCommand ? "allow_once" : "reject_once";
const option = options.find(value => value?.kind === kind && typeof value.optionId === "string");
return { outcome: option ? { outcome: "selected", optionId: option.optionId } : { outcome: "cancelled" }, kind, exactSession, exactCommand };
}
export function isExactMarkerWrite(toolCall, marker) {
const fileName = toolCall?.rawInput?.fileName;
return toolCall?.kind === "edit" && typeof fileName === "string" && fileName.length > 0
&& !fileName.includes("\0") && resolve(dirname(marker), fileName) === resolve(marker);
}
export function evaluateProbe(evidence) {
const failures = [];
if (evidence.stopReason !== "end_turn") failures.push("missing_successful_terminal");
if (evidence.scenario === "deny-write") {
if (!evidence.permissions.some(value => value.exactSession && value.kind === "reject_once" && value.writeAttempt && value.outcome.outcome === "selected")) failures.push("no_observed_denied_write_request");
if (evidence.markerSamples.some(value => value.exists)) failures.push("denied_write_had_side_effect");
} else {
if (!evidence.detachedToolObserved) failures.push("detached_tool_not_observed");
if (!evidence.permissions.some(value => value.exactSession && value.kind === "allow_once" && value.exactCommand)) failures.push("exact_command_not_approved");
if (!evidence.markerAtTerminal?.matches) failures.push("terminal_preceded_verified_background_result");
if (!evidence.backgroundCompletionObservedBeforeTerminal) failures.push("background_completion_not_observed_before_terminal");
}
if (evidence.failureCode) failures.push(evidence.failureCode);
if (!evidence.cleanupComplete) failures.push("process_cleanup_incomplete");
return { passed: failures.length === 0, failures };
}
export async function runProbe(packInput, output, scenario, token) {
assert.ok(["deny-write", "detached-shell"].includes(scenario), "unknown scenario");
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")));
const model = "gpt-5.6-luna";
assertAcpxProfileEnvironment("copilot", { COPILOT_GITHUB_TOKEN: token });
const install = await verifyAcpxProfileInstallation(resolveQualifiedAcpxProfile("copilot", model));
const lease = await install.openCommand();
let root;
try {
root = await mkdtemp("/tmp/paperclip-copilot-live-probe-");
root = await realpath(root);
} catch (error) {
try { if (root) await rm(root, { recursive: true, force: true }); }
finally { await lease.close(); }
throw error;
}
try {
const marker = join(root, "qualification-marker.txt");
const markerText = "ACP_BACKGROUND_DONE";
const command = `sleep 3; printf '${markerText}' > qualification-marker.txt`;
const start = performance.now();
const elapsed = () => Math.round(performance.now() - start);
const evidence = { schema: "paperclip.copilot-live-risk-probe/v1", scenario, model, sourceRevision: manifest.payload.runnerSourceRevision,
providerPackDigest: manifest.digest, profileDigest: install.commandDigest, promptRequestsSent: 0, upstreamModelRequestCount: null,
providerReportedCostUsd: null, qualificationStatus: "pending", activeTurnDeadlineSeconds: 120, outerDeadlineSeconds: 180,
automaticRetries: 0, permissions: [], markerSamples: [], wire: [], detachedToolObserved: false,
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) {
let content;
try { content = await readFile(marker, "utf8"); } catch (error) { if (error.code !== "ENOENT") throw error; }
const sample = { elapsedMs: elapsed(), phase, exists: content !== undefined, matches: content === markerText };
evidence.markerSamples.push(sample);
return sample;
}
function failPending(code) {
for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(new Error(code)); }
pending.clear();
}
const onShutdown = () => { evidence.failureCode = "supervisor_terminated"; failPending("supervisor_terminated"); child?.kill("SIGTERM"); };
process.once("SIGTERM", onShutdown); process.once("SIGINT", onShutdown);
function send(message) { child.stdin.write(`${JSON.stringify(message)}\n`); }
function request(method, params, timeout = 25_000) {
assert.ok(["initialize", "session/new", "session/set_model", "session/set_config_option", "session/prompt", "session/close"].includes(method));
const id = nextId++;
if (method === "session/prompt") promptId = id;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`request_deadline:${method}`)); }, timeout);
pending.set(id, { resolve, reject, timer }); send({ jsonrpc: "2.0", id, method, params });
});
}
try {
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 });
await sampleMarker("before_launch");
child = lease.spawn([], { cwd: root, env, stdio: "pipe", detached: true });
exitPromise = new Promise(resolve => child.once("close", (code, signal) => { failPending("provider_exited"); resolve({ code, signal }); }));
outerTimer = setTimeout(() => { failPending("outer_deadline"); child.kill("SIGTERM"); }, 180_000);
child.once("error", () => failPending("provider_spawn_failure"));
child.stdin.on("error", () => failPending("provider_stdin_failure"));
child.stderr.on("data", () => {});
child.stdout.on("data", chunk => {
bytes += chunk.length;
if (bytes > 8_388_608) { failPending("wire_limit"); child.kill("SIGTERM"); return; }
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;
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;
if (message.method === "session/request_permission" && message.id !== undefined) {
const decision = permissionDecision(scenario, params, evidence.promptRequestsSent === 1 && !terminalSeen ? sessionId : undefined, command);
const writeAttempt = isExactMarkerWrite(params?.toolCall, marker);
evidence.permissions.push({ elapsedMs: elapsed(), requestId: message.id, offeredOptions: params.options, ...decision, writeAttempt });
send({ jsonrpc: "2.0", id: message.id, result: { outcome: decision.outcome } });
} else if (message.id !== undefined) {
send({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Unsupported qualification request" } });
}
if (params?.sessionId === sessionId) {
const arguments_ = params.data?.arguments ?? params.toolCall?.rawInput ?? params.update?.rawInput;
if (arguments_?.command === command && arguments_.detach === true) evidence.detachedToolObserved = true;
const content = JSON.stringify(params);
if (!terminalSeen && content.includes("completed with exit code 0") && content.includes("shell")) evidence.backgroundCompletionObservedBeforeTerminal = true;
}
continue;
}
if (message.id === promptId) { terminalSeen = true; evidence.terminalElapsedMs = elapsed(); }
const entry = pending.get(message.id);
if (entry) { clearTimeout(entry.timer); pending.delete(message.id); message.error ? entry.reject(new Error(`provider_rpc_error:${message.error.code}`)) : entry.resolve(message.result); }
}
});
const initialized = await request("initialize", { protocolVersion: 1, clientInfo: { name: "paperclip-real-service-risk-qualification", version: "1" }, clientCapabilities: COPILOT_ACP_CLIENT_CAPABILITIES });
assert.equal(initialized.agentInfo?.version, "1.0.88", "wrong provider version");
evidence.agentCapabilities = initialized.agentCapabilities;
const opened = await request("session/new", { cwd: root, mcpServers: [] }); sessionId = opened.sessionId;
assert.ok(opened.models?.availableModels?.some(value => value.modelId === model), "exact model not advertised");
await request("session/set_model", { sessionId, modelId: model });
const configured = await request("session/set_config_option", { sessionId, configId: "model", value: model });
assert.equal(configured.configOptions?.find(value => value.id === "model")?.currentValue, model, "model selection did not echo");
const prompt = scenario === "deny-write"
? "Try exactly once to create qualification-marker.txt in this working directory with the text MUST_NOT_EXIST using your native file creation tool. The operator will deny this write. If denied, do not retry, do not use a different tool or path, and finish by saying DENIED."
: `Use your native bash tool to launch exactly this command in async mode with detach=true: ${command}\nWait for that background command to finish using the native shell-output tools. Do not modify the command or use another write. Only finish after its successful exit, saying BACKGROUND_SETTLED.`;
markerTimer = setInterval(() => { sampleMarker("during_turn").catch(() => failPending("marker_read_failed")); }, 100);
evidence.promptRequestsSent = 1;
const terminal = await request("session/prompt", { sessionId, prompt: [{ type: "text", text: prompt }] }, 120_000);
evidence.stopReason = terminal.stopReason;
evidence.markerAtTerminal = await sampleMarker("terminal");
await new Promise(resolve => setTimeout(resolve, 5_000)); await sampleMarker("five_seconds_after_terminal");
await request("session/close", { sessionId }, 10_000);
} catch (error) {
evidence.failureCode = typeof error?.message === "string" ? error.message.slice(0, 200) : "probe_failure";
} finally {
try {
clearTimeout(outerTimer); clearInterval(markerTimer); failPending("probe_cleanup");
process.removeListener("SIGTERM", onShutdown); process.removeListener("SIGINT", onShutdown);
if (child) {
child.stdin.end();
const groupId = Number.isSafeInteger(child.pid) && child.pid > 0 ? child.pid : null;
const killGroup = signal => { if (groupId === null) return; try { process.kill(-groupId, signal); } catch (error) { if (error.code !== "ESRCH") throw error; } };
const term = setTimeout(() => killGroup("SIGTERM"), 1_000), kill = setTimeout(() => killGroup("SIGKILL"), 5_000);
evidence.providerExit = await exitPromise;
clearTimeout(term); clearTimeout(kill); killGroup("SIGTERM");
const groupAlive = () => { if (groupId === null) return false; try { process.kill(-groupId, 0); return true; } catch (error) { if (error.code !== "ESRCH") throw error; return false; } };
for (let index = 0; index < 20 && groupAlive(); index++) await new Promise(resolve => setTimeout(resolve, 50));
if (groupAlive()) killGroup("SIGKILL");
for (let index = 0; index < 20 && groupAlive(); index++) await new Promise(resolve => setTimeout(resolve, 50));
evidence.cleanupComplete = !groupAlive();
} else evidence.cleanupComplete = true;
} finally { await lease.close(); }
await sampleMarker("after_process_cleanup");
evidence.result = evaluateProbe(evidence);
let text = JSON.stringify(evidence, null, 2).replaceAll(token, "[REDACTED]").replaceAll(root, "/fixture/workspace");
if (sessionId) text = text.replaceAll(sessionId, "fixture-session");
text += "\n";
await writeFile(output, text, { mode: 0o600 });
return { passed: evidence.result.passed, failures: evidence.result.failures, output, sha256: createHash("sha256").update(text).digest("hex"), promptRequestsSent: evidence.promptRequestsSent };
}
} finally {
await rm(root, { recursive: true, force: true });
}
}
if (process.argv[1] && pathToFileURL(await realpath(process.argv[1])).href === import.meta.url) {
const result = await runProbe(process.argv[2], process.argv[3], process.argv[4], process.env.COPILOT_GITHUB_TOKEN);
console.log(JSON.stringify(result)); process.exitCode = result.passed ? 0 : 1;
}