Files
PaperClipAI/packages/paperclip-runner/scripts/materialize-opencode-binary.test.mjs
T
DottaandPaperclip f6a211479f fix: share current CLI runtimes across sandbox adapters (#12994)
## 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>
2026-09-07 10:09:29 -05:00

108 lines
3.0 KiB
JavaScript

import assert from "node:assert/strict";
import {
chmod,
mkdir,
mkdtemp,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, test } from "node:test";
import { materializePinnedOpenCodeBinary } from "./materialize-opencode-binary.mjs";
const temporaryDirectories = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true })),
);
});
async function fixture(options = {}) {
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-binary-"));
temporaryDirectories.push(root);
const packageRoot = join(root, "opencode-ai");
const baselineRoot = join(root, "opencode-linux-x64-baseline");
await Promise.all([
mkdir(join(packageRoot, "bin"), { recursive: true }),
mkdir(join(baselineRoot, "bin"), { recursive: true }),
]);
await Promise.all([
writeFile(
join(packageRoot, "package.json"),
JSON.stringify({
name: "opencode-ai",
version: options.packageVersion ?? "1.18.29",
}),
),
writeFile(
join(baselineRoot, "package.json"),
JSON.stringify({
name: "opencode-linux-x64-baseline",
version: options.baselineVersion ?? "1.18.29",
}),
),
writeFile(join(packageRoot, "bin", "opencode.exe"), "sentinel\n"),
]);
const source = join(baselineRoot, "bin", "opencode");
if (options.symlinkSource) {
const realSource = join(root, "real-opencode");
await writeFile(realSource, "#!/bin/sh\necho 1.18.29\n");
await chmod(realSource, 0o755);
await symlink(realSource, source);
} else {
await writeFile(source, "#!/bin/sh\necho 1.18.29\n");
await chmod(source, 0o755);
}
return packageRoot;
}
test("materializes the pinned baseline executable with a verified version", async () => {
const packageRoot = await fixture();
const result = materializePinnedOpenCodeBinary({
packageRoot,
platform: "linux",
architecture: "x64",
});
assert.equal(result.version, "1.18.29");
assert.match(result.sourceDigest, /^[0-9a-f]{64}$/);
});
test("refuses version, file-type, and platform drift", async () => {
const wrongVersion = await fixture({ baselineVersion: "1.18.18" });
assert.throws(
() =>
materializePinnedOpenCodeBinary({
packageRoot: wrongVersion,
platform: "linux",
architecture: "x64",
}),
/Expected opencode-linux-x64-baseline@1\.18\.29/,
);
const symlinkSource = await fixture({ symlinkSource: true });
assert.throws(
() =>
materializePinnedOpenCodeBinary({
packageRoot: symlinkSource,
platform: "linux",
architecture: "x64",
}),
/source executable is not a regular file/,
);
const unsupported = await fixture();
assert.throws(
() =>
materializePinnedOpenCodeBinary({
packageRoot: unsupported,
platform: "darwin",
architecture: "arm64",
}),
/requires linux\/x64/,
);
});