diff --git a/.github/workflows/runner-full-stack-e2e.yml b/.github/workflows/runner-full-stack-e2e.yml index 772d61f062..31e67c0f82 100644 --- a/.github/workflows/runner-full-stack-e2e.yml +++ b/.github/workflows/runner-full-stack-e2e.yml @@ -711,18 +711,18 @@ jobs: uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: ${{ needs.build_runner_artifacts.outputs.build_artifact_name }} - path: runner-e2e-build + path: ${{ runner.temp }}/runner-e2e-build - name: Verify and restore shared TypeScript outputs if: needs.catalog.outputs.needs_remote_provider_pack == 'true' run: | set -euo pipefail ( - cd runner-e2e-build + cd "$RUNNER_TEMP/runner-e2e-build" sha256sum --check runner-e2e-build-bundle.tar.gz.sha256 ) tar --extract --gzip \ - --file runner-e2e-build/runner-e2e-build-bundle.tar.gz \ + --file "$RUNNER_TEMP/runner-e2e-build/runner-e2e-build-bundle.tar.gz" \ --directory "$GITHUB_WORKSPACE" test -d packages/paperclip-eval-kernel/dist test -d packages/paperclip-runner/dist @@ -920,14 +920,14 @@ jobs: uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: ${{ needs.build_runner_artifacts.outputs.build_artifact_name }} - path: runner-e2e-build + path: ${{ runner.temp }}/runner-e2e-build - name: Download immutable remote provider pack if: matrix.environmentId == 'daytona' && (matrix.profileId == 'runner-opencode' || startsWith(matrix.profileId, 'runner-acpx-')) uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: ${{ needs.build_remote_provider_pack.outputs.provider_pack_artifact_name }} - path: runner-e2e-provider-pack + path: ${{ runner.temp }}/runner-e2e-provider-pack - name: Verify and restore campaign outputs if: startsWith(matrix.profileId, 'runner-') || matrix.suiteId == 'openrouter-model-breadth' @@ -937,11 +937,11 @@ jobs: run: | set -euo pipefail ( - cd runner-e2e-build + cd "$RUNNER_TEMP/runner-e2e-build" sha256sum --check runner-e2e-build-bundle.tar.gz.sha256 ) tar --extract --gzip \ - --file runner-e2e-build/runner-e2e-build-bundle.tar.gz \ + --file "$RUNNER_TEMP/runner-e2e-build/runner-e2e-build-bundle.tar.gz" \ --directory "$GITHUB_WORKSPACE" test -d packages/paperclip-eval-kernel/dist if [ "$NEEDS_RUNNER_TYPESCRIPT" = true ]; then @@ -958,11 +958,11 @@ jobs: run: | set -euo pipefail ( - cd runner-e2e-provider-pack + cd "$RUNNER_TEMP/runner-e2e-provider-pack" sha256sum --check runner-e2e-provider-pack.tar.gz.sha256 ) tar --extract --gzip \ - --file runner-e2e-provider-pack/runner-e2e-provider-pack.tar.gz \ + --file "$RUNNER_TEMP/runner-e2e-provider-pack/runner-e2e-provider-pack.tar.gz" \ --directory "$GITHUB_WORKSPACE" jq -e \ --arg revision "$IMAGE_SOURCE_REVISION" \ diff --git a/doc/plans/2026-10-06-hermes-native-runner.md b/doc/plans/2026-10-06-hermes-native-runner.md index 7be37be588..6fa6475585 100644 --- a/doc/plans/2026-10-06-hermes-native-runner.md +++ b/doc/plans/2026-10-06-hermes-native-runner.md @@ -1125,3 +1125,12 @@ runner binary. Master changed Rust inputs, so the retained Mac binary cannot qualify the synchronized source. Fresh Rust builds run in cloud CI. A new Mac binary, current image, and the outstanding live matrix remain qualification requirements. No local Docker or Rust build is used for this synchronization. + +The trusted E2E workflow now downloads its checksum-bound build and provider-pack +archives under `RUNNER_TEMP`, outside the controller checkout. Source admission +checks every tracked change and all other untracked files. It excludes only +untracked content inside the generated Mac/Linux Hermes assets and provider-pack +roots; separate verified runtime admission still checks their manifests and +bytes before credentials. A 43-test source/workflow selection passes, including +tracked asset edits, staged/deleted files, root symlinks, neighboring unmanaged +assets, and archive placement. These are fixture checks, not paid workflow proof. diff --git a/tests/runner-e2e/hermes-source.test.ts b/tests/runner-e2e/hermes-source.test.ts index 4345202568..24afe23f84 100644 --- a/tests/runner-e2e/hermes-source.test.ts +++ b/tests/runner-e2e/hermes-source.test.ts @@ -1,6 +1,6 @@ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { devNull, tmpdir } from "node:os"; import { afterEach, describe, expect, it } from "vitest"; @@ -102,3 +102,35 @@ describe("approved qualification dependency lock", () => { })).toThrow(); }); }); + +describe("generated runtime source admission", () => { + const assetRoot = "packages/paperclip-runner/provider-assets/hermes/linux-x64"; + function generated(root: string, relative: string) { + const directory = join(root, relative); mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, "manifest.json"), "fixture runtime bytes\n"); + } + it("admits untracked Mac/Linux runtime assets and a verified pack without hiding source changes", () => { + const f = checkout(); + for (const root of [assetRoot, "packages/paperclip-runner/provider-assets/hermes/darwin-arm64", "packages/paperclip-runner/provider-pack"]) generated(f.root, root); + expect(prepareHermesQualificationSource(selected, f.root, { PATH: process.env.PATH })).toMatchObject({ sha: f.sha, workingTreeClean: true }); + writeFileSync(join(f.root, "source.txt"), "changed source\n"); + expect(() => prepareHermesQualificationSource(selected, f.root, { PATH: process.env.PATH })).toThrow("clean checkout before credentials"); + }); + it.each(["runner-e2e-build", "runner-e2e-provider-pack", "packages/paperclip-runner/provider-assets/hermes/win32-x64", "packages/paperclip-runner/provider-assets/unmanaged"])("rejects untracked files in %s", root => { + const f = checkout(); generated(f.root, assetRoot); generated(f.root, root); + expect(() => prepareHermesQualificationSource(selected, f.root, { PATH: process.env.PATH })).toThrow("clean checkout before credentials"); + }); + it.each(["modified", "staged", "deleted"])("rejects %s tracked files inside a generated asset root", kind => { + const f = checkout(); generated(f.root, assetRoot); f.git("add", assetRoot); + f.git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "-c", "commit.gpgsign=false", "-c", `core.hooksPath=${devNull}`, "commit", "-m", "tracked asset fixture"); + const file = join(f.root, assetRoot, "manifest.json"); + if (kind === "deleted") rmSync(file); else writeFileSync(file, "changed tracked asset\n"); + if (kind === "staged") f.git("add", assetRoot); + expect(() => prepareHermesQualificationSource(selected, f.root, { PATH: process.env.PATH })).toThrow("clean checkout before credentials"); + }); + it("rejects a runtime-root symlink instead of treating it as generated content", () => { + const f = checkout(); const parent = join(f.root, "packages/paperclip-runner/provider-assets/hermes"); mkdirSync(parent, { recursive: true }); + symlinkSync(f.root, join(parent, "linux-x64")); + expect(() => prepareHermesQualificationSource(selected, f.root, { PATH: process.env.PATH })).toThrow("clean checkout before credentials"); + }); +}); diff --git a/tests/runner-e2e/hermes-source.ts b/tests/runner-e2e/hermes-source.ts index a6a31af047..3b92dfb80e 100644 --- a/tests/runner-e2e/hermes-source.ts +++ b/tests/runner-e2e/hermes-source.ts @@ -30,7 +30,19 @@ export function prepareHermesQualificationSource( }; const sha = git(["rev-parse", "HEAD"]).trim(); if (!/^[a-f0-9]{40}$/.test(sha)) throw new Error("Hermes qualification source SHA is invalid."); - const changes = git(["status", "--porcelain", "-z", "--untracked-files=normal"]).split("\0").filter(Boolean); + // Check every tracked source change, including anything tracked in an asset + // directory. Only untracked files in these generated runtime roots are omitted; + // their manifests and bytes are verified separately before credentials arrive. + const generatedRuntimeRoots = [ + "packages/paperclip-runner/provider-assets/hermes/linux-x64", + "packages/paperclip-runner/provider-assets/hermes/darwin-arm64", + "packages/paperclip-runner/provider-pack", + ]; + const trackedChanges = git(["status", "--porcelain", "-z", "--untracked-files=no"]).split("\0").filter(Boolean); + const untrackedSource = git(["ls-files", "--others", "--exclude-standard", "-z", "--", ".", + ...generatedRuntimeRoots.map(root => `:(exclude)${root}/**`), + ]).split("\0").filter(Boolean); + const changes = [...trackedChanges, ...untrackedSource.map(file => `?? ${file}`)]; const approvedLock = environment.PAPERCLIP_RUNNER_E2E_LOCK_SHA256?.trim(); if (approvedLock && !/^[a-f0-9]{64}$/.test(approvedLock)) throw new Error("Hermes qualification approved lock digest is invalid."); if (changes.some(change => change !== " M pnpm-lock.yaml" || !approvedLock)) { diff --git a/tests/runner-e2e/workflow-security.test.ts b/tests/runner-e2e/workflow-security.test.ts index d6fad7e81a..ad46dbc737 100644 --- a/tests/runner-e2e/workflow-security.test.ts +++ b/tests/runner-e2e/workflow-security.test.ts @@ -79,6 +79,17 @@ describe("public repository paid workflow security", () => { expect(provision).toContain("uv==0.12.17"); expect(provision).toContain("scripts/provision-hermes.mjs"); }); + it("downloads and verifies qualification archives outside controller source", async () => { + const workflow = await readFile(path.join(repositoryRoot, ".github/workflows/runner-full-stack-e2e.yml"), "utf8"); + expect(workflow.match(/path: \$\{\{ runner.temp \}\}\/runner-e2e-build/gu)).toHaveLength(2); + expect(workflow.match(/path: \$\{\{ runner.temp \}\}\/runner-e2e-provider-pack/gu)).toHaveLength(1); + expect(workflow.match(/cd "\$RUNNER_TEMP\/runner-e2e-build"/gu)).toHaveLength(2); + expect(workflow).toContain('cd "$RUNNER_TEMP/runner-e2e-provider-pack"'); + expect(workflow.match(/--file "\$RUNNER_TEMP\/runner-e2e-build\/runner-e2e-build-bundle.tar.gz"/gu)).toHaveLength(2); + expect(workflow).toContain('--file "$RUNNER_TEMP/runner-e2e-provider-pack/runner-e2e-provider-pack.tar.gz"'); + expect(workflow).not.toMatch(/path: runner-e2e-(?:build|provider-pack)\n/u); + expect(workflow).not.toMatch(/--file runner-e2e-(?:build|provider-pack)\//u); + }); it("keeps the manual EC2 image build credential-free and pins the authorized target", async () => { const workflow = await readFile(path.join(repositoryRoot, ".github/workflows/docker-runner-check.yml"), "utf8"); const manual = workflow.slice(workflow.indexOf(" authorize_manual:"));