From 313d6ca11536da73eb60ab27f2f10b5732dea2d8 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:12:17 -0500 Subject: [PATCH] fix(runner): materialize pinned OpenCode binary (#12782) ## Thinking Path > - Paperclip manages AI agents and their provider runtimes. > - Paid runner validation installs target dependencies with lifecycle scripts disabled. > - OpenCode leaves a sentinel executable until its package lifecycle script runs. > - Running arbitrary lifecycle code would weaken the paid-secret boundary. > - This pull request materializes one exact pinned binary before secrets are exposed. > - The benefit is working OpenCode validation without trusting dependency install scripts. ## Linked Issues or Issue Description **What happened?** Every local OpenCode paid cell stopped before provider startup because `pnpm install --ignore-scripts` correctly retained `opencode-ai/bin/opencode.exe` as a sentinel. **Expected behavior** The trusted workflow must make the exact lockfile-pinned OpenCode executable available without running package lifecycle scripts. **Steps to reproduce** Run a local legacy or native OpenCode paid cell from the trusted workflow after the target dependency install. The provider health check reports that the OpenCode postinstall script was not run. **Paperclip version or commit** Default branch commit `865b4854fb44d3689f1c0ff17e3e715d52aaea73`. ## What Changed - Materialize only `opencode-linux-x64-baseline@1.18.17` into the matching `opencode-ai@1.18.17` package. - Verify package identity, version, regular-file type, SHA-256 equality, executable permissions, and runtime `--version`. - Invoke the helper for local OpenCode and breadth cells and for remote provider-pack assembly. - Retain `pnpm install --ignore-scripts`. - Add helper and trusted-workflow security regressions. ## Verification - Helper syntax checks passed. - Helper unit tests passed: 2/2. - Workflow-security tests passed: 5/5. - Prettier, actionlint, and diff whitespace checks passed. ## Risks Risk is low and contained to paid runner setup. The helper supports only Linux x64, fails closed on package or version drift, and runs before provider credentials enter the job. ## Model Used OpenAI GPT-5 Codex with repository tools and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change. - [x] I have specified the model used. - [x] I have checked ROADMAP.md and confirmed this does not duplicate planned core work. - [x] I have searched GitHub for duplicate or related PRs and found none. - [x] I have described the issue in this PR with the bug template labels. - [x] I have not referenced internal or instance-local issues. - [x] My branch name describes the change. - [x] Focused local tests pass. - [x] I added tests for the change. - [x] I updated the runner E2E security documentation. - [x] I documented the risks above. --- .github/workflows/runner-full-stack-e2e.yml | 8 ++ packages/paperclip-runner/package.json | 2 +- .../scripts/materialize-opencode-binary.mjs | 109 ++++++++++++++++++ .../materialize-opencode-binary.test.mjs | 107 +++++++++++++++++ tests/runner-e2e/SECURITY.md | 5 +- tests/runner-e2e/workflow-security.test.ts | 14 +++ 6 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 packages/paperclip-runner/scripts/materialize-opencode-binary.mjs create mode 100644 packages/paperclip-runner/scripts/materialize-opencode-binary.test.mjs diff --git a/.github/workflows/runner-full-stack-e2e.yml b/.github/workflows/runner-full-stack-e2e.yml index 31719b0d3f..0568f01781 100644 --- a/.github/workflows/runner-full-stack-e2e.yml +++ b/.github/workflows/runner-full-stack-e2e.yml @@ -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: diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index 6f05a2cb52..dcff595e2f 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -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 && 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/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::", diff --git a/packages/paperclip-runner/scripts/materialize-opencode-binary.mjs b/packages/paperclip-runner/scripts/materialize-opencode-binary.mjs new file mode 100644 index 0000000000..37cb04a85a --- /dev/null +++ b/packages/paperclip-runner/scripts/materialize-opencode-binary.mjs @@ -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`); +} diff --git a/packages/paperclip-runner/scripts/materialize-opencode-binary.test.mjs b/packages/paperclip-runner/scripts/materialize-opencode-binary.test.mjs new file mode 100644 index 0000000000..b6f7bbfe7d --- /dev/null +++ b/packages/paperclip-runner/scripts/materialize-opencode-binary.test.mjs @@ -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/, + ); +}); diff --git a/tests/runner-e2e/SECURITY.md b/tests/runner-e2e/SECURITY.md index cba5da13d6..1b812b225d 100644 --- a/tests/runner-e2e/SECURITY.md +++ b/tests/runner-e2e/SECURITY.md @@ -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. diff --git a/tests/runner-e2e/workflow-security.test.ts b/tests/runner-e2e/workflow-security.test.ts index 1393260125..9f6c0039d0 100644 --- a/tests/runner-e2e/workflow-security.test.ts +++ b/tests/runner-e2e/workflow-security.test.ts @@ -306,6 +306,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( @@ -339,6 +342,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");