mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-07 07:23:08 +02:00
## Thinking Path - Paperclip Runner needs its runtime preinstalled for fast sandbox startup. - Native and local adapters should launch one current CLI installation per provider. - An older global copy can shadow that installation, and exact native compatibility pins must match it. - Update the qualified releases and binary digests, expose shared CLI entrypoints from the provider pack, and prefer the image-owned bin directory. - Keep dependency installation in the image build; task startup only discovers, links, and verifies artifacts. ## Linked Issues or Issue Description **What happened?** Remote native startup rejected a stale global Codex, while CLI-only images lacked runnerd entirely. **Expected behavior** An image-baked runtime starts without uploading binaries or installing packages. All adapters share the same current provider CLI. **Steps to reproduce** Start a native remote task with the old global Codex and the updated runtime available only under `/opt/paperclip-runner/bin`. **Paperclip version or commit** Discovery behavior at `54a99d884`. **Deployment mode** Docker with a remote sandbox. ## What Changed - Prefer `/opt/paperclip-runner/bin`, then the user's local bin directory, then PATH. Existing metadata and version validation remains in force. - Qualify Codex 0.153.4, OpenCode 1.18.29, and Claude SDK 0.3.263 / CLI 2.1.263. Update binary digests, TypeScript/Rust checks, registry defaults, and the displayed OpenCode version together. - Share Codex and Claude's native executable with the ACP bridges through exact dependency overrides. Preserve the separately qualified ACP bridge implementations and their security patches. - Expose shared provider-pack CLI launchers; fail the pack build if Codex ACP resolves a separate Codex installation. Update the eval image's other agent CLIs to current stable releases and remove duplicate global provider installs. - Document the single-current-CLI policy in source comments and development guidance. Latest stable releases are resolved at review/build preparation and pinned; task startup never auto-updates. ## Verification - Native-session and adapter-registry suites: 158 tests passed. - Provider suites: 88 tests passed, 7 Linux-only checks skipped on macOS. One existing macOS temporary-path alias assertion passed when rerun with canonical `TMPDIR=/private/tmp`. - Package-contract and OpenCode materialization tests: 11 passed. - Full typecheck, build, and token gates passed. Rust native-provider/recovery tests: 19 passed. - Broad local suite: 5,974 passed, 23 failed, 41 skipped. Failures are in unchanged macOS workspace/path/port and connection suites; focused runtime tests pass. All latest-head Linux PR checks passed, including the full test shards, typecheck, build, runner verification, browser suites, and canary dry run. - The standalone fleet image built with one current provider CLI each and passed native Codex/Claude binary-integrity checks. A disposable Daytona sandbox reported ready in 798 ms; its baked runner completed an API-key `gpt-5.6-luna` turn in 2,430 ms and returned the expected marker with a usage receipt. No runtime artifacts were uploaded or installed. - The normal shared `codex exec` entrypoint also completed an API-key `gpt-5.6-luna` turn in 2,321 ms. - Both image builds verify the complete generated lockfile against a reviewed SHA-256 before package installation or lifecycle execution. Root lockfile changes remain CI-owned. Merge and rollout remain on hold for operator review. ## Risks - Updating provider CLIs changes their behavior for all adapters; version probes and live native smoke testing are required before image promotion. - The image-owned directory takes precedence. Its entries must launch the same shared CLI as the global PATH, not a private older/newer copy. - Application qualification pins and the deployed image must move together. No startup fallback installation is added. - No schema or authentication-policy changes. ## Model Used OpenAI GPT-6 (Codex). The session does not expose a more specific model ID or context-window size. Used reasoning, repository inspection, code execution, and browser 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 - [x] 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>
110 lines
3.3 KiB
JavaScript
110 lines
3.3 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import { spawnSync } from "node:child_process";
|
|
import {
|
|
chmodSync,
|
|
copyFileSync,
|
|
existsSync,
|
|
linkSync,
|
|
lstatSync,
|
|
readFileSync,
|
|
realpathSync,
|
|
unlinkSync,
|
|
} from "node:fs";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
const OPENCODE_VERSION = "1.18.29";
|
|
const BASELINE_PACKAGE = "opencode-linux-x64-baseline";
|
|
|
|
function readPackage(path) {
|
|
return JSON.parse(readFileSync(path, "utf8"));
|
|
}
|
|
|
|
function sha256(path) {
|
|
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
}
|
|
|
|
function assertPackage(packageRoot, expectedName) {
|
|
const packageJson = readPackage(join(packageRoot, "package.json"));
|
|
if (
|
|
packageJson.name !== expectedName ||
|
|
packageJson.version !== OPENCODE_VERSION
|
|
) {
|
|
throw new Error(
|
|
`Expected ${expectedName}@${OPENCODE_VERSION}, received ${String(packageJson.name)}@${String(packageJson.version)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
export function materializePinnedOpenCodeBinary(options = {}) {
|
|
const platform = options.platform ?? process.platform;
|
|
const architecture = options.architecture ?? process.arch;
|
|
if (platform !== "linux" || architecture !== "x64") {
|
|
throw new Error(
|
|
`Pinned OpenCode materialization requires linux/x64, received ${platform}/${architecture}`,
|
|
);
|
|
}
|
|
|
|
const packageRoot = realpathSync(
|
|
options.packageRoot ??
|
|
resolve(import.meta.dirname, "../node_modules/opencode-ai"),
|
|
);
|
|
const dependencyRoot = dirname(packageRoot);
|
|
const baselineRoot = realpathSync(join(dependencyRoot, BASELINE_PACKAGE));
|
|
assertPackage(packageRoot, "opencode-ai");
|
|
assertPackage(baselineRoot, BASELINE_PACKAGE);
|
|
|
|
const source = join(baselineRoot, "bin", "opencode");
|
|
const target = join(packageRoot, "bin", "opencode.exe");
|
|
if (!lstatSync(source).isFile()) {
|
|
throw new Error("Pinned OpenCode source executable is not a regular file");
|
|
}
|
|
if (existsSync(target)) {
|
|
if (!lstatSync(target).isFile()) {
|
|
throw new Error("OpenCode target executable is not a regular file");
|
|
}
|
|
unlinkSync(target);
|
|
}
|
|
try {
|
|
linkSync(source, target);
|
|
} catch (error) {
|
|
const code = error?.code;
|
|
if (!new Set(["EACCES", "EMLINK", "EPERM", "EXDEV"]).has(code)) {
|
|
throw error;
|
|
}
|
|
copyFileSync(source, target);
|
|
}
|
|
chmodSync(target, 0o755);
|
|
|
|
const sourceDigest = sha256(source);
|
|
const targetDigest = sha256(target);
|
|
if (sourceDigest !== targetDigest) {
|
|
throw new Error("Materialized OpenCode executable digest mismatch");
|
|
}
|
|
const targetStat = lstatSync(target);
|
|
const mode = targetStat.mode & 0o777;
|
|
if (!targetStat.isFile() || (mode & 0o111) === 0 || mode & 0o022) {
|
|
throw new Error("Materialized OpenCode executable has unsafe permissions");
|
|
}
|
|
|
|
const version = spawnSync(target, ["--version"], {
|
|
encoding: "utf8",
|
|
timeout: 30_000,
|
|
windowsHide: true,
|
|
});
|
|
if (version.status !== 0 || version.stdout.trim() !== OPENCODE_VERSION) {
|
|
throw new Error(
|
|
`Materialized OpenCode executable did not report ${OPENCODE_VERSION}`,
|
|
);
|
|
}
|
|
return { sourceDigest, target, version: OPENCODE_VERSION };
|
|
}
|
|
|
|
const invokedPath = process.argv[1]
|
|
? pathToFileURL(realpathSync(process.argv[1])).href
|
|
: null;
|
|
if (invokedPath === import.meta.url) {
|
|
const result = materializePinnedOpenCodeBinary();
|
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
}
|