ci(runner): materialize pinned OpenCode binary
This commit is contained in:
parent
d238d6c01e
commit
a673fca0a3
|
|
@ -619,6 +619,10 @@ jobs:
|
|||
- if: needs.catalog.outputs.needs_remote_provider_pack == 'true'
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Materialize verified pinned OpenCode executable
|
||||
if: needs.catalog.outputs.needs_remote_provider_pack == 'true'
|
||||
run: node packages/paperclip-runner/scripts/materialize-opencode-binary.mjs
|
||||
|
||||
- name: Download immutable shared campaign outputs
|
||||
if: needs.catalog.outputs.needs_remote_provider_pack == 'true'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
|
|
@ -762,6 +766,10 @@ jobs:
|
|||
# the protected environment during setup.
|
||||
- run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Materialize verified pinned OpenCode executable
|
||||
if: matrix.environmentId == 'local' && (matrix.profileId == 'legacy-opencode' || matrix.profileId == 'runner-opencode' || matrix.suiteId == 'openrouter-model-breadth')
|
||||
run: node packages/paperclip-runner/scripts/materialize-opencode-binary.mjs
|
||||
|
||||
- name: Download immutable campaign outputs
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -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 && 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/materialize-opencode-binary.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::",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
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.17";
|
||||
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`);
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
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.17",
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
join(baselineRoot, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "opencode-linux-x64-baseline",
|
||||
version: options.baselineVersion ?? "1.18.17",
|
||||
}),
|
||||
),
|
||||
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.17\n");
|
||||
await chmod(realSource, 0o755);
|
||||
await symlink(realSource, source);
|
||||
} else {
|
||||
await writeFile(source, "#!/bin/sh\necho 1.18.17\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.17");
|
||||
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\.17/,
|
||||
);
|
||||
|
||||
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/,
|
||||
);
|
||||
});
|
||||
|
|
@ -31,8 +31,9 @@ must never run repository lifecycle scripts. The shared-build and provider-pack
|
|||
jobs also receive no provider credentials and disable dependency lifecycle
|
||||
scripts; they package outputs with SHA-256 sidecars that consumers verify
|
||||
before extraction. The paid test job installs with lifecycle scripts disabled,
|
||||
and provider secrets are scoped only to its final test step rather than
|
||||
dependency setup. Report sanitization and AWS
|
||||
and materializes the exact pinned OpenCode executable from its lockfile-verified
|
||||
optional package without invoking package lifecycle code. Provider secrets are
|
||||
scoped only to the final test step rather than dependency setup. Report sanitization and AWS
|
||||
history publication explicitly use the trusted workflow commit and do not
|
||||
consume the target lockfile. Never run the workflow definition from the target
|
||||
branch.
|
||||
|
|
|
|||
|
|
@ -309,6 +309,9 @@ describe("public repository paid workflow security", () => {
|
|||
expect(buildJob).toContain(
|
||||
"node packages/paperclip-runner/scripts/build-provider-pack.mjs",
|
||||
);
|
||||
expect(buildJob).toContain(
|
||||
"node packages/paperclip-runner/scripts/materialize-opencode-binary.mjs",
|
||||
);
|
||||
expect(buildJob).toContain("runner-e2e-build-bundle.tar.gz.sha256");
|
||||
expect(buildJob).toContain("runner-e2e-provider-pack.tar.gz.sha256");
|
||||
expect(buildJob).toContain(
|
||||
|
|
@ -342,6 +345,17 @@ describe("public repository paid workflow security", () => {
|
|||
);
|
||||
expect(testJob).toContain(".payload.runnerSourceRevision == $revision");
|
||||
expect(workflow).toContain("Qualify local provider Node interpreter");
|
||||
expect(testJob).toContain(
|
||||
"Materialize verified pinned OpenCode executable",
|
||||
);
|
||||
expect(testJob).toContain(
|
||||
"matrix.profileId == 'legacy-opencode' || matrix.profileId == 'runner-opencode' || matrix.suiteId == 'openrouter-model-breadth'",
|
||||
);
|
||||
expect(testJob).toContain(
|
||||
"node packages/paperclip-runner/scripts/materialize-opencode-binary.mjs",
|
||||
);
|
||||
expect(testJob).not.toContain("postinstall.mjs");
|
||||
expect(testJob).not.toContain("pnpm rebuild");
|
||||
expect(testJob).not.toContain("build:typescript");
|
||||
expect(testJob).not.toContain("build:runner-binaries");
|
||||
expect(testJob).not.toContain("build-provider-pack.mjs");
|
||||
|
|
|
|||
Loading…
Reference in New Issue