mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
## 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>
385 lines
15 KiB
JavaScript
385 lines
15 KiB
JavaScript
import profiles from "../acpx-profiles.json" with { type: "json" };
|
|
import { createHash } from "node:crypto";
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
import {
|
|
chmodSync,
|
|
copyFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
readlinkSync,
|
|
realpathSync,
|
|
renameSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join, relative, resolve } from "node:path";
|
|
import { createRequire } from "node:module";
|
|
import { fileURLToPath } from "node:url";
|
|
import { parseProviderPackArguments, materializeCandidateProviderPack, providerPackProviders, providerPackManifestFields } from "./candidate-provider-pack.mjs";
|
|
import { buildNodeStartupTimeout } from "./build-node-startup-timeout.mjs";
|
|
import { writePortableExecutableShim, writePortableCopilotShims } from "./provider-pack-executable-shims.mjs";
|
|
import { removeControllerArtifactsFromProviderPack } from "./provider-pack-runtime-tree.mjs";
|
|
|
|
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
const workspaceRoot = resolve(packageRoot, "../..");
|
|
const { output: outputArgument, candidates } = parseProviderPackArguments(process.argv.slice(2));
|
|
const outputRoot = resolve(
|
|
process.cwd(),
|
|
outputArgument ?? join(packageRoot, "provider-pack"),
|
|
);
|
|
if (
|
|
outputRoot === workspaceRoot ||
|
|
outputRoot === packageRoot ||
|
|
outputRoot === "/"
|
|
) {
|
|
throw new Error(`Refusing unsafe provider-pack output path: ${outputRoot}`);
|
|
}
|
|
|
|
const temporaryParent = realpathSync(mkdtempSync(join(tmpdir(), "paperclip-provider-pack-")));
|
|
const temporaryRoot = join(temporaryParent, "pack");
|
|
|
|
function canonicalJson(value) {
|
|
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
if (value && typeof value === "object") {
|
|
return `{${Object.keys(value)
|
|
.sort()
|
|
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
|
|
.join(",")}}`;
|
|
}
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function sha256File(path) {
|
|
return `sha256:${createHash("sha256")
|
|
.update(readFileSync(path))
|
|
.digest("hex")}`;
|
|
}
|
|
|
|
function sha256Tree(root) {
|
|
const hash = createHash("sha256");
|
|
const visit = (directory, prefix = "") => {
|
|
const entries = readdirSync(directory, { withFileTypes: true }).sort(
|
|
(left, right) => left.name.localeCompare(right.name),
|
|
);
|
|
for (const entry of entries) {
|
|
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
const absolutePath = join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
hash.update(`directory\0${relativePath}\n`);
|
|
visit(absolutePath, relativePath);
|
|
} else if (entry.isFile()) {
|
|
hash.update(`file\0${relativePath}\0${sha256File(absolutePath)}\n`);
|
|
} else if (entry.isSymbolicLink()) {
|
|
hash.update(
|
|
`symlink\0${relativePath}\0${readlinkSync(absolutePath)}\n`,
|
|
);
|
|
} else {
|
|
throw new Error(
|
|
`Provider pack tree contains unsupported entry ${relativePath}`,
|
|
);
|
|
}
|
|
}
|
|
};
|
|
visit(root);
|
|
return `sha256:${hash.digest("hex")}`;
|
|
}
|
|
|
|
function writePortableNodeShim(name, entrypoint) {
|
|
const shimPath = join(temporaryRoot, "node_modules", ".bin", name);
|
|
writeFileSync(
|
|
shimPath,
|
|
[
|
|
"#!/bin/sh",
|
|
"set -eu",
|
|
'basedir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)',
|
|
`exec "$basedir/../node/bin/node" "$basedir/../${entrypoint}" "$@"`,
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
chmodSync(shimPath, 0o755);
|
|
}
|
|
|
|
try {
|
|
const deployed = spawnSync(
|
|
"pnpm",
|
|
[
|
|
"--filter",
|
|
"@paperclipai/paperclip-runner",
|
|
"deploy",
|
|
"--prod",
|
|
temporaryRoot,
|
|
],
|
|
{ cwd: workspaceRoot, encoding: "utf8", stdio: "inherit" },
|
|
);
|
|
if (deployed.status !== 0) {
|
|
throw new Error(`pnpm deploy failed with exit code ${deployed.status}`);
|
|
}
|
|
removeControllerArtifactsFromProviderPack(temporaryRoot);
|
|
|
|
// Fail the image build if a bridge silently brings back an older/private
|
|
// provider CLI. A direct dependency alone does not deduplicate pnpm's graph.
|
|
const packRequire = createRequire(join(temporaryRoot, "package.json"));
|
|
const codexAcpRequire = createRequire(packRequire.resolve("@agentclientprotocol/codex-acp/package.json"));
|
|
if (realpathSync(codexAcpRequire.resolve("@openai/codex/package.json")) !==
|
|
realpathSync(packRequire.resolve("@openai/codex/package.json"))) {
|
|
throw new Error("Codex ACP must share the image's Codex installation");
|
|
}
|
|
|
|
// Reuse the already-qualified build interpreter instead of introducing a
|
|
// package-manager lifecycle hook or a second binary supply chain. The pack
|
|
// manifest binds the copied bytes, platform, architecture, and minimum
|
|
// version before any provider is launched.
|
|
const minimumNodeVersion = [24, 11, 0];
|
|
const actualNodeVersion = process.versions.node.split(".").map(Number);
|
|
if (
|
|
actualNodeVersion[0] < minimumNodeVersion[0] ||
|
|
(actualNodeVersion[0] === minimumNodeVersion[0] &&
|
|
(actualNodeVersion[1] < minimumNodeVersion[1] ||
|
|
(actualNodeVersion[1] === minimumNodeVersion[1] &&
|
|
actualNodeVersion[2] < minimumNodeVersion[2])))
|
|
) {
|
|
throw new Error("Provider pack build Node is older than 24.11.0");
|
|
}
|
|
const stableNodeRoot = join(temporaryRoot, "node_modules", "node");
|
|
if (existsSync(stableNodeRoot)) {
|
|
throw new Error(
|
|
"Provider pack deployment unexpectedly claimed the stable Node path",
|
|
);
|
|
}
|
|
const stableNodeCommand = join(stableNodeRoot, "bin", "node");
|
|
mkdirSync(dirname(stableNodeCommand), { recursive: true, mode: 0o755 });
|
|
copyFileSync(process.execPath, stableNodeCommand);
|
|
chmodSync(stableNodeCommand, 0o755);
|
|
// A freshly copied x64 Mach-O can stall when launched directly from a
|
|
// translated Node process. Select Rosetta explicitly for this build probe;
|
|
// native Intel and every other platform still execute the copied bytes directly.
|
|
const translated = process.platform === "darwin" && process.arch === "x64"
|
|
&& spawnSync("/usr/sbin/sysctl", ["-n", "sysctl.proc_translated"], {
|
|
encoding: "utf8", timeout: 1_000,
|
|
}).stdout?.trim() === "1";
|
|
const relocatedNode = spawnSync(translated ? "/usr/bin/arch" : stableNodeCommand,
|
|
translated ? ["-x86_64", stableNodeCommand, "--version"] : ["--version"], {
|
|
cwd: temporaryRoot, env: { PATH: "/usr/bin:/bin" }, encoding: "utf8", timeout: buildNodeStartupTimeout(),
|
|
});
|
|
if (relocatedNode.status !== 0 || relocatedNode.stdout.trim() !== `v${process.versions.node}`) {
|
|
throw new Error("Provider pack Node is not portable after relocation; build with a standalone Node distribution");
|
|
}
|
|
|
|
const candidateProviders = {};
|
|
for (const provider of providerPackProviders(process.platform, process.arch, candidates)) {
|
|
const assetPath = `provider-assets/${provider}/${process.platform}-${process.arch}`;
|
|
const metadata = await materializeCandidateProviderPack({ provider, outputRoot: join(temporaryRoot, assetPath) });
|
|
if (typeof metadata?.version !== "string" || !metadata.version || metadata.version.length > 120
|
|
|| !/^sha256:[a-f0-9]{64}$/.test(metadata.profileDigest)
|
|
|| !/^sha256:[a-f0-9]{64}$/.test(metadata.closureDigest)) throw new Error("Candidate builder omitted its pinned identity");
|
|
candidateProviders[provider] = { version: metadata.version, profileDigest: metadata.profileDigest,
|
|
closureDigest: metadata.closureDigest, qualification: ["pi", "cursor", "copilot"].includes(provider) ? "qualified" : "pending", path: assetPath,
|
|
sha256: sha256Tree(join(temporaryRoot, assetPath)) };
|
|
}
|
|
|
|
// pnpm's generated .bin shims embed the temporary deployment directory in
|
|
// NODE_PATH. That makes an otherwise identical provider pack hash differ on
|
|
// every build and leaks a nonexistent host path after relocation. Replace
|
|
// every provider-facing shim with a pack-relative launcher that always uses
|
|
// the pinned Node executable owned by this pack.
|
|
// The image exposes these same installations to every adapter. Never add a
|
|
// separate global/runner-only CLI version; refresh these packages and their
|
|
// qualification digests together to the latest stable releases.
|
|
writePortableNodeShim("codex", "@openai/codex/bin/codex.js");
|
|
const claudeAcpRequire = createRequire(
|
|
packRequire.resolve("@agentclientprotocol/claude-agent-acp/package.json"),
|
|
);
|
|
// Use the ACP bridge's SDK dependency directly, avoiding a second peer-
|
|
// resolved SDK installation just to expose its CLI on the global PATH.
|
|
const sdkRequire = createRequire(claudeAcpRequire.resolve("@anthropic-ai/claude-agent-sdk"));
|
|
const claudeExecutable = sdkRequire.resolve(
|
|
`@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}/claude`,
|
|
);
|
|
writePortableExecutableShim(
|
|
temporaryRoot,
|
|
"claude",
|
|
relative(realpathSync(join(temporaryRoot, "node_modules")), realpathSync(claudeExecutable)),
|
|
);
|
|
writePortableExecutableShim(temporaryRoot, "node", "node/bin/node");
|
|
writePortableExecutableShim(temporaryRoot, "opencode", "opencode-ai/bin/opencode.exe");
|
|
writePortableCopilotShims(temporaryRoot);
|
|
writePortableNodeShim("acpx", "acpx/dist/cli.js");
|
|
writePortableNodeShim("paperclip-runner-copilot-metadata-probe", "../dist/cli/copilot-metadata-probe.js");
|
|
writePortableNodeShim(
|
|
"claude-agent-acp",
|
|
"@agentclientprotocol/claude-agent-acp/dist/index.js",
|
|
);
|
|
writePortableNodeShim(
|
|
"codex-acp",
|
|
"@agentclientprotocol/codex-acp/dist/index.js",
|
|
);
|
|
|
|
// pnpm deploy may retain a workspace self-link under its virtual store. It
|
|
// points outside the immutable pack and is not needed by the deployed
|
|
// package, so it must not be staged or captured.
|
|
rmSync(
|
|
join(
|
|
temporaryRoot,
|
|
"node_modules",
|
|
".pnpm",
|
|
"node_modules",
|
|
"@paperclipai",
|
|
"paperclip-runner",
|
|
),
|
|
{ recursive: true, force: true },
|
|
);
|
|
|
|
for (const shimName of readdirSync(
|
|
join(temporaryRoot, "node_modules", ".bin"),
|
|
)) {
|
|
const shimPath = join(temporaryRoot, "node_modules", ".bin", shimName);
|
|
const contents = readFileSync(shimPath, "utf8");
|
|
if (contents.includes(temporaryParent)) {
|
|
throw new Error(
|
|
`Provider pack shim ${shimName} retains its temporary build path`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Exercise a clean import from the deployed dependency graph. This catches
|
|
// malformed patch hunks that can look correct in an existing pnpm store but
|
|
// land inside the wrong function when applied to a fresh acpx tarball.
|
|
const acpxImport = spawnSync(
|
|
join(temporaryRoot, "node_modules", "node", "bin", "node"),
|
|
["--input-type=module", "-e", 'await import("acpx/runtime")'],
|
|
{
|
|
cwd: temporaryRoot,
|
|
encoding: "utf8",
|
|
timeout: 30_000,
|
|
},
|
|
);
|
|
if (acpxImport.status !== 0) {
|
|
throw new Error(
|
|
`Provider pack ACPX import failed: ${String(acpxImport.stderr ?? "")
|
|
.trim()
|
|
.slice(-2_000)}`,
|
|
);
|
|
}
|
|
|
|
const opencodeProxyPath = "dist/cli/opencode-app-server-proxy.cjs";
|
|
const acpxSidecarPath = "dist/cli/acpx-runtime-sidecar.cjs";
|
|
const opencodeCommand = "node_modules/.bin/opencode";
|
|
const opencodeExecutable = "node_modules/opencode-ai/bin/opencode.exe";
|
|
const nodeCommand = "node_modules/node/bin/node";
|
|
const productionLock = "pnpm-lock.yaml";
|
|
copyFileSync(
|
|
join(workspaceRoot, "pnpm-lock.yaml"),
|
|
join(temporaryRoot, productionLock),
|
|
);
|
|
for (const relativePath of [
|
|
nodeCommand,
|
|
productionLock,
|
|
opencodeProxyPath,
|
|
acpxSidecarPath,
|
|
opencodeCommand,
|
|
opencodeExecutable,
|
|
]) {
|
|
if (!existsSync(join(temporaryRoot, relativePath))) {
|
|
throw new Error(`Provider pack is missing ${relativePath}`);
|
|
}
|
|
}
|
|
|
|
const opencodeProxySha = sha256File(join(temporaryRoot, opencodeProxyPath));
|
|
const acpxSidecarSha = sha256File(join(temporaryRoot, acpxSidecarPath));
|
|
const distDigest = sha256Tree(join(temporaryRoot, "dist"));
|
|
const configuredRevision =
|
|
process.env.PAPERCLIP_RUNNER_SOURCE_REVISION?.trim();
|
|
const revision =
|
|
configuredRevision ??
|
|
execFileSync("git", ["rev-parse", "HEAD"], {
|
|
cwd: workspaceRoot,
|
|
encoding: "utf8",
|
|
}).trim();
|
|
if (!/^[0-9a-f]{40}$/.test(revision)) {
|
|
throw new Error("PAPERCLIP_RUNNER_SOURCE_REVISION must be a full Git SHA");
|
|
}
|
|
const dirty = configuredRevision
|
|
? false
|
|
: spawnSync("git", ["diff", "--quiet", "--", "packages/paperclip-runner"], {
|
|
cwd: workspaceRoot,
|
|
}).status !== 0;
|
|
const payload = {
|
|
pins: {
|
|
nodeMinimum: minimumNodeVersion.join("."),
|
|
codex: profiles.profiles.codex.agentRuntimeVersion,
|
|
opencode: "1.18.34",
|
|
acpx: profiles.acpxVersion,
|
|
grok: profiles.profiles.grok.agentRuntimeVersion,
|
|
claudeAcp: profiles.profiles.claude.agentServerVersion,
|
|
codexAcp: profiles.profiles.codex.agentServerVersion,
|
|
},
|
|
target: { platform: process.platform, architecture: process.arch },
|
|
runnerSourceRevision: `${revision}${dirty ? "-dirty" : ""}`,
|
|
distDigest,
|
|
bridgeDigest: `sha256:${createHash("sha256")
|
|
.update(opencodeProxySha)
|
|
.update("\n")
|
|
.update(acpxSidecarSha)
|
|
.update("\n")
|
|
.update(distDigest)
|
|
.digest("hex")}`,
|
|
acpxProfileDigests: {
|
|
grok: profiles.profiles.grok.commandDigest,
|
|
claude:
|
|
profiles.profiles.claude.commandDigest,
|
|
codex:
|
|
profiles.profiles.codex.commandDigest,
|
|
},
|
|
...providerPackManifestFields(candidateProviders, candidates),
|
|
artifacts: {
|
|
grokLauncher: {
|
|
path: "dist/providers/grok/launcher.cjs",
|
|
sha256: sha256File(join(temporaryRoot, "dist/providers/grok/launcher.cjs")),
|
|
},
|
|
nodeCommand: {
|
|
path: nodeCommand,
|
|
sha256: sha256File(join(temporaryRoot, nodeCommand)),
|
|
},
|
|
productionLock: {
|
|
path: productionLock,
|
|
sha256: sha256File(join(temporaryRoot, productionLock)),
|
|
},
|
|
opencodeCommand: {
|
|
path: opencodeCommand,
|
|
sha256: sha256File(join(temporaryRoot, opencodeCommand)),
|
|
},
|
|
opencodeExecutable: {
|
|
path: opencodeExecutable,
|
|
sha256: sha256File(join(temporaryRoot, opencodeExecutable)),
|
|
},
|
|
opencodeProxy: {
|
|
path: opencodeProxyPath,
|
|
sha256: opencodeProxySha,
|
|
},
|
|
acpxSidecar: { path: acpxSidecarPath, sha256: acpxSidecarSha },
|
|
},
|
|
};
|
|
const manifest = {
|
|
schema: "paperclip-runner/remote-provider-pack/v1",
|
|
digest: `sha256:${createHash("sha256")
|
|
.update(canonicalJson(payload))
|
|
.digest("hex")}`,
|
|
payload,
|
|
};
|
|
writeFileSync(
|
|
join(temporaryRoot, "provider-pack.json"),
|
|
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
{ mode: 0o600 },
|
|
);
|
|
|
|
rmSync(outputRoot, { recursive: true, force: true });
|
|
renameSync(temporaryRoot, outputRoot);
|
|
process.stdout.write(`${outputRoot}\n`);
|
|
} finally {
|
|
rmSync(temporaryParent, { recursive: true, force: true });
|
|
}
|