From d9157142e9c58b621f295cf53d1bb47252b98ef6 Mon Sep 17 00:00:00 2001 From: Martin Cleary Date: Tue, 11 Aug 2026 01:00:36 +0100 Subject: [PATCH] fix: synchronize ClawSweeper dispatch identity (#3449) --- .github/workflows/clawsweeper-dispatch.yml | 41 +++- scripts/clawsweeper-dispatch-workflow.test.ts | 179 ++++++++++++++++++ 2 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 scripts/clawsweeper-dispatch-workflow.test.ts diff --git a/.github/workflows/clawsweeper-dispatch.yml b/.github/workflows/clawsweeper-dispatch.yml index 46424622..31bd5258 100644 --- a/.github/workflows/clawsweeper-dispatch.yml +++ b/.github/workflows/clawsweeper-dispatch.yml @@ -56,6 +56,7 @@ jobs: env: GH_TOKEN: ${{ steps.token.outputs.token }} TARGET_REPO: ${{ github.repository }} + TARGET_BRANCH: ${{ github.event.repository.default_branch }} ITEM_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }} ITEM_KIND: ${{ github.event_name == 'pull_request_target' && 'pull_request' || 'issue' }} SOURCE_EVENT: ${{ github.event_name }} @@ -65,14 +66,52 @@ jobs: echo "::notice::Skipping ClawSweeper dispatch because no dispatch credential is configured." exit 0 fi + ingress_fingerprint="$(node <<'NODE' + const crypto = require("node:crypto"); + const fs = require("node:fs"); + const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); + const pullRequest = event.pull_request && typeof event.pull_request === "object" + ? event.pull_request + : {}; + const headSha = String(pullRequest.head?.sha || "").trim().toLowerCase(); + const updatedAt = String(pullRequest.updated_at || "").trim(); + if ( + process.env.ITEM_KIND !== "pull_request" || + !/^[0-9a-f]{40}$/.test(headSha) || + !updatedAt + ) { + process.stdout.write(""); + } else { + process.stdout.write( + crypto + .createHash("sha256") + .update( + JSON.stringify({ + version: 1, + target_repo: String(process.env.TARGET_REPO || "").toLowerCase(), + item_number: Number(process.env.ITEM_NUMBER), + action: String(process.env.SOURCE_ACTION || ""), + head_sha: headSha, + updated_at: updatedAt, + body: typeof pullRequest.body === "string" ? pullRequest.body : "", + label: String(event.label?.name || ""), + }), + ) + .digest("hex"), + ); + } + NODE + )" payload="$(jq -nc \ --arg target_repo "$TARGET_REPO" \ + --arg target_branch "$TARGET_BRANCH" \ --argjson item_number "$ITEM_NUMBER" \ --arg item_kind "$ITEM_KIND" \ --arg source_event "$SOURCE_EVENT" \ --arg source_action "$SOURCE_ACTION" \ + --arg ingress_fingerprint "$ingress_fingerprint" \ --argjson supersedes_in_progress "$SUPERSEDES_IN_PROGRESS" \ - '{event_type:"clawsweeper_item",client_payload:{target_repo:$target_repo,item_number:$item_number,item_kind:$item_kind,source_event:$source_event,source_action:$source_action,supersedes_in_progress:$supersedes_in_progress}}')" + '{event_type:"clawsweeper_item",client_payload:({target_repo:$target_repo,target_branch:$target_branch,item_number:$item_number,item_kind:$item_kind,source_event:$source_event,source_action:$source_action,supersedes_in_progress:$supersedes_in_progress} + (if $ingress_fingerprint != "" then {ingress_route:"target_dispatcher",ingress_fingerprint:$ingress_fingerprint} else {} end))}')" gh api repos/openclaw/clawsweeper/dispatches \ --method POST \ --input - <<< "$payload" diff --git a/scripts/clawsweeper-dispatch-workflow.test.ts b/scripts/clawsweeper-dispatch-workflow.test.ts new file mode 100644 index 00000000..8acedba2 --- /dev/null +++ b/scripts/clawsweeper-dispatch-workflow.test.ts @@ -0,0 +1,179 @@ +/* @vitest-environment node */ + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { parse as parseYaml } from "yaml"; + +type WorkflowStep = { + env?: Record; + name?: string; + run?: string; +}; + +const behaviorIt = process.platform === "win32" ? it.skip : it; + +function executeExactReview(run: string, event: object, environment: Record) { + const directory = mkdtempSync(join(tmpdir(), "clawhub-clawsweeper-dispatch-")); + const eventPath = join(directory, "event.json"); + const capturePath = join(directory, "dispatch.json"); + const scriptPath = join(directory, "dispatch.sh"); + const ghPath = join(directory, "gh"); + + try { + writeFileSync(eventPath, JSON.stringify(event), "utf8"); + writeFileSync(scriptPath, `#!/usr/bin/env bash\nset -euo pipefail\n${run}\n`, "utf8"); + writeFileSync( + ghPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'test "$#" -eq 6', + 'test "$1" = "api"', + 'test "$2" = "repos/openclaw/clawsweeper/dispatches"', + 'test "$3" = "--method"', + 'test "$4" = "POST"', + 'test "$5" = "--input"', + 'test "$6" = "-"', + 'cat > "$GH_CAPTURE"', + ].join("\n"), + "utf8", + ); + chmodSync(scriptPath, 0o755); + chmodSync(ghPath, 0o755); + + const result = spawnSync("bash", [scriptPath], { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + ...environment, + GH_CAPTURE: capturePath, + GH_TOKEN: "proof-token", + GITHUB_EVENT_PATH: eventPath, + PATH: `${directory}:${process.env.PATH ?? ""}`, + SUPERSEDES_IN_PROGRESS: "false", + }, + }); + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + return JSON.parse(readFileSync(capturePath, "utf8")) as { + event_type: string; + client_payload: Record; + }; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +describe("ClawSweeper dispatch workflow", () => { + it("carries trusted branch authority and canonical PR ingress identity", async () => { + const workflow = parseYaml( + await readFile(".github/workflows/clawsweeper-dispatch.yml", "utf8"), + ) as { + jobs: { dispatch: { steps: WorkflowStep[] } }; + }; + const exactReview = workflow.jobs.dispatch.steps.find( + (step) => step.name === "Dispatch exact ClawSweeper review", + ); + + expect(exactReview?.env?.TARGET_BRANCH).toBe("${{ github.event.repository.default_branch }}"); + + const run = exactReview?.run ?? ""; + expect(run).toContain('--arg target_branch "$TARGET_BRANCH"'); + expect(run).toContain("target_branch:$target_branch"); + expect(run).toContain('process.env.ITEM_KIND !== "pull_request"'); + expect(run).toContain("/^[0-9a-f]{40}$/.test(headSha)"); + expect(run).toContain("version: 1"); + expect(run).toContain('target_repo: String(process.env.TARGET_REPO || "").toLowerCase()'); + expect(run).toContain("item_number: Number(process.env.ITEM_NUMBER)"); + expect(run).toContain('action: String(process.env.SOURCE_ACTION || "")'); + expect(run).toContain("head_sha: headSha"); + expect(run).toContain("updated_at: updatedAt"); + expect(run).toContain('body: typeof pullRequest.body === "string" ? pullRequest.body : ""'); + expect(run).toContain('label: String(event.label?.name || "")'); + expect(run).toContain('ingress_route:"target_dispatcher"'); + expect(run).toContain("ingress_fingerprint:$ingress_fingerprint"); + }); + + behaviorIt("serializes PR identity and leaves issue dispatches unpaired", async () => { + const workflow = parseYaml( + await readFile(".github/workflows/clawsweeper-dispatch.yml", "utf8"), + ) as { + jobs: { dispatch: { steps: WorkflowStep[] } }; + }; + const run = + workflow.jobs.dispatch.steps.find((step) => step.name === "Dispatch exact ClawSweeper review") + ?.run ?? ""; + const pullRequest = { + head: { sha: "A".repeat(40) }, + updated_at: "2026-08-10T22:00:00Z", + body: "proof body", + }; + const prPayload = executeExactReview( + run, + { pull_request: pullRequest, label: { name: "proof: sufficient" } }, + { + TARGET_REPO: "openclaw/clawhub", + TARGET_BRANCH: "trunk", + ITEM_NUMBER: "3359", + ITEM_KIND: "pull_request", + SOURCE_EVENT: "pull_request_target", + SOURCE_ACTION: "synchronize", + }, + ); + const fingerprint = createHash("sha256") + .update( + JSON.stringify({ + version: 1, + target_repo: "openclaw/clawhub", + item_number: 3359, + action: "synchronize", + head_sha: "a".repeat(40), + updated_at: pullRequest.updated_at, + body: pullRequest.body, + label: "proof: sufficient", + }), + ) + .digest("hex"); + expect(prPayload).toEqual({ + event_type: "clawsweeper_item", + client_payload: { + target_repo: "openclaw/clawhub", + target_branch: "trunk", + item_number: 3359, + item_kind: "pull_request", + source_event: "pull_request_target", + source_action: "synchronize", + supersedes_in_progress: false, + ingress_route: "target_dispatcher", + ingress_fingerprint: fingerprint, + }, + }); + + const issuePayload = executeExactReview( + run, + { issue: { number: 3360 } }, + { + TARGET_REPO: "openclaw/clawhub", + TARGET_BRANCH: "trunk", + ITEM_NUMBER: "3360", + ITEM_KIND: "issue", + SOURCE_EVENT: "issues", + SOURCE_ACTION: "opened", + }, + ); + expect(issuePayload.client_payload).toEqual({ + target_repo: "openclaw/clawhub", + target_branch: "trunk", + item_number: 3360, + item_kind: "issue", + source_event: "issues", + source_action: "opened", + supersedes_in_progress: false, + }); + }); +});