diff --git a/.github/workflows/docker-runner-check.yml b/.github/workflows/docker-runner-check.yml index 19e1c2d08b4..b1e47393c65 100644 --- a/.github/workflows/docker-runner-check.yml +++ b/.github/workflows/docker-runner-check.yml @@ -3,6 +3,10 @@ name: Docker Runner check on: workflow_dispatch: inputs: + target_branch: + description: "Branch in this repository to build; resolved to one immutable commit before checkout" + type: string + required: false publish_eval_image: description: "Publish an immutable Daytona qualification image on the EC2 fleet (no provider credentials)" type: boolean @@ -58,8 +62,11 @@ jobs: permissions: contents: read timeout-minutes: 5 + outputs: + target_sha: ${{ steps.authorize.outputs.target_sha }} steps: - name: Authorize an explicit maintainer image build + id: authorize env: GH_TOKEN: ${{ github.token }} REPOSITORY: ${{ github.repository }} @@ -67,6 +74,7 @@ jobs: ACTOR_ID: ${{ github.actor_id }} TRIGGERING_ACTOR: ${{ github.triggering_actor }} ALLOWED_IDS: ${{ vars.AWS_CI_TRUSTED_USER_IDS }} + TARGET_BRANCH: ${{ inputs.target_branch || github.ref_name }} run: | set -euo pipefail test "$REPOSITORY" = paperclipai/paperclip @@ -76,6 +84,9 @@ jobs: for id in "$ACTOR_ID" "$triggering_id"; do jq -e --argjson id "$id" 'index($id) != null' <<< "$ALLOWED_IDS" >/dev/null done + target_sha="$(gh api "repos/$REPOSITORY/git/ref/heads/$TARGET_BRANCH" --jq '.object.sha')" + [[ "$target_sha" =~ ^[0-9a-f]{40}$ ]] + echo "target_sha=$target_sha" >> "$GITHUB_OUTPUT" manual_image: name: Build and verify on EC2 @@ -105,6 +116,7 @@ jobs: done - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + ref: ${{ needs.authorize_manual.outputs.target_sha }} persist-credentials: false - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 with: @@ -129,7 +141,7 @@ jobs: - name: Build checksum-verified Grok provider image if: inputs.publish_eval_image env: - SOURCE_SHA: ${{ github.sha }} + SOURCE_SHA: ${{ needs.authorize_manual.outputs.target_sha }} run: | set -euo pipefail image="ghcr.io/paperclipai/paperclip-daytona-runner:qualification-${SOURCE_SHA}-${GITHUB_RUN_ID}" diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts index 9d53e2cab1b..2f4598c894e 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts @@ -70,6 +70,27 @@ describe("Codex ACPX runtime adapter", () => { } }); + it.each(["PERMISSION_PROMPT_UNAVAILABLE", "AGENT_DISCONNECTED"])("preserves the meaning of ACPX terminal failure %s", async (code) => { + const runtime = fakeRuntime(); + const result = { status: "failed" as const, error: { code, message: "Provider failed" } }; + vi.mocked(runtime.startTurn).mockReturnValue({ + requestId: "permission-fallback", promptStarted: Promise.resolve(), + events: { async *[Symbol.asyncIterator]() {} }, result: Promise.resolve(result), + cancel: vi.fn(), closeStream: vi.fn(), + }); + const port = await openCodexAcpxRuntime(openOptions(fakeCommand()), { + createRegistry: () => registry(), createStore: () => store(), createRuntime: () => runtime, + }); + try { + const turn = port.startTurn({ text: "Attempt a write", requestId: "permission-fallback" }); + if (code === "PERMISSION_PROMPT_UNAVAILABLE") { + await expect(turn.result).rejects.toMatchObject({ code: "approval_required" }); + } else { + await expect(turn.result).resolves.toEqual(result); + } + } finally { await port.close({ reason: "terminal permission outcome verified" }); } + }); + it("rejects a pre-aborted admission before constructing or spawning ACPX", async () => { const cancellation = new Error("runtime admission cancelled"); const controller = new AbortController(); diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts index 3ca58e36877..1444b9d4206 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts @@ -1230,7 +1230,15 @@ function runtimePort( } const guarded = turnWithVerifiedLifetimeOwnership(turn, finishOwnershipAdmission); const result = guarded.result.then( - (value) => { approval.signal.throwIfAborted(); return value; }, + (value) => { + approval.signal.throwIfAborted(); + // ACPX may resolve its own permission policy before invoking the + // host callback. Keep that typed denial on the same runner outcome. + if (value.status === "failed" && value.error?.code === "PERMISSION_PROMPT_UNAVAILABLE") { + throw new AcpxApprovalRequiredError(); + } + return value; + }, (error: unknown) => { approval.signal.throwIfAborted(); throw error; }, ).finally(() => { if (permissionBoundary.active === approval) permissionBoundary.active = null; diff --git a/tests/runner-e2e/workflow-security.test.ts b/tests/runner-e2e/workflow-security.test.ts index c627ca44c44..f02dfdd90be 100644 --- a/tests/runner-e2e/workflow-security.test.ts +++ b/tests/runner-e2e/workflow-security.test.ts @@ -15,6 +15,21 @@ const everydayOracleImage = "python@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285"; describe("public repository paid workflow security", () => { + 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:")); + expect(manual.match(/AWS_CI_TRUSTED_USER_IDS/gu)).toHaveLength(2); + expect(manual.match(/test "\$REPOSITORY_ID" = 1170821064/gu)).toHaveLength(2); + expect(manual).toContain('runs-on: runs-on/fleet=paperclip-public-pr-x64/env=public-ci'); + expect(manual).toContain('repos/$REPOSITORY/git/ref/heads/$TARGET_BRANCH'); + expect(manual).toContain('ref: ${{ needs.authorize_manual.outputs.target_sha }}'); + expect(manual).toContain('SOURCE_SHA: ${{ needs.authorize_manual.outputs.target_sha }}'); + expect([...manual.matchAll(/secrets\.([A-Z_]+)/gu)].map(match => match[1])).toEqual(["GITHUB_TOKEN"]); + expect(manual).toContain('pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile'); + expect(manual).toContain('PAPERCLIP_RUNNER_LOCK_SHA256=$lock_sha'); + expect(manual).toContain('docker logout ghcr.io'); + }); + it("uses the reviewed master branch for the first-party trusted PR workflow", async () => { const ordinaryPrWorkflow = await readFile( path.join(repositoryRoot, ".github/workflows/pr.yml"),