fix: dispatch ClawScan through repository events (#3031)

This commit is contained in:
Patrick Erichsen
2026-07-08 22:33:20 -07:00
committed by GitHub
parent dfeb1682ec
commit 8d70da7d76
5 changed files with 75 additions and 45 deletions
+5 -3
View File
@@ -1,6 +1,8 @@
name: Security Scan Codex Worker
on:
repository_dispatch:
types: [clawhub-security-scan]
workflow_dispatch:
inputs:
limit:
@@ -39,9 +41,9 @@ jobs:
shard: [0, 1, 2, 3]
env:
CONVEX_URL: ${{ vars.CONVEX_URL || vars.VITE_CONVEX_URL || 'https://wry-manatee-359.convex.cloud' }}
CODEX_SECURITY_SCAN_LIMIT: ${{ inputs.limit || inputs['batch-limit'] || '4' }}
CODEX_SECURITY_SCAN_MAX_JOBS: ${{ inputs['max-jobs'] || '' }}
CODEX_SECURITY_SCAN_MAX_RUNTIME_MINUTES: ${{ inputs['max-runtime-minutes'] || '8' }}
CODEX_SECURITY_SCAN_LIMIT: ${{ github.event.client_payload.batch_limit || inputs.limit || inputs['batch-limit'] || '4' }}
CODEX_SECURITY_SCAN_MAX_JOBS: ${{ github.event.client_payload.max_jobs || inputs['max-jobs'] || '' }}
CODEX_SECURITY_SCAN_MAX_RUNTIME_MINUTES: ${{ github.event.client_payload.max_runtime_minutes || inputs['max-runtime-minutes'] || '8' }}
CODEX_SECURITY_SCAN_TIMEOUT_MS: ${{ vars.CODEX_SECURITY_SCAN_TIMEOUT_MS || '240000' }}
CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN: "1"
CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX: ${{ vars.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX || 'docker' }}
+34 -22
View File
@@ -3,6 +3,7 @@ import {
beginSecurityScanDispatchInternal,
dispatchSecurityScanWorkflow,
finishSecurityScanDispatchInternal,
getGitHubRepositoryDispatchPermission,
requestSecurityScanDispatchInternal,
} from "./securityScanDispatch";
@@ -352,26 +353,19 @@ describe("securityScanDispatch", () => {
);
});
it("refuses to dispatch when the GitHub App lacks Actions write permission", async () => {
const fetchImpl = vi.fn();
await expect(
dispatchSecurityScanWorkflow(
{
token: "installation-token",
permissions: { actions: "read", contents: "read" },
},
fetchImpl,
),
).resolves.toEqual({
ok: false,
reason: "actions-write-required",
it("reports whether the GitHub App can create repository dispatches", () => {
expect(getGitHubRepositoryDispatchPermission({ contents: "read" })).toEqual({
contentsPermission: "read",
canDispatch: false,
});
expect(getGitHubRepositoryDispatchPermission({ contents: "write" })).toEqual({
contentsPermission: "write",
canDispatch: true,
});
expect(fetchImpl).not.toHaveBeenCalled();
});
it("dispatches the production workflow on main with bounded worker inputs", async () => {
const fetchImpl = vi.fn(async () => new Response(null, { status: 204 }));
it("refuses to dispatch when the GitHub App lacks Contents write permission", async () => {
const fetchImpl = vi.fn();
await expect(
dispatchSecurityScanWorkflow(
@@ -381,20 +375,38 @@ describe("securityScanDispatch", () => {
},
fetchImpl,
),
).resolves.toEqual({
ok: false,
reason: "contents-write-required",
});
expect(fetchImpl).not.toHaveBeenCalled();
});
it("dispatches the production workflow through a narrowly typed repository event", async () => {
const fetchImpl = vi.fn(async () => new Response(null, { status: 204 }));
await expect(
dispatchSecurityScanWorkflow(
{
token: "installation-token",
permissions: { contents: "write" },
},
fetchImpl,
),
).resolves.toEqual({ ok: true });
expect(fetchImpl).toHaveBeenCalledWith(
"https://api.github.com/repos/openclaw/clawhub/actions/workflows/security-scan-codex.yml/dispatches",
"https://api.github.com/repos/openclaw/clawhub/dispatches",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
Authorization: "Bearer installation-token",
}),
body: JSON.stringify({
ref: "main",
inputs: {
"batch-limit": "4",
"max-runtime-minutes": "8",
event_type: "clawhub-security-scan",
client_payload: {
batch_limit: "4",
max_runtime_minutes: "8",
},
}),
}),
+23 -17
View File
@@ -7,8 +7,7 @@ import { createGitHubAppInstallationToken, isGitHubAppConfigured } from "./lib/g
const DISPATCH_STATE_KEY = "codex-worker";
const DISPATCH_LEASE_MS = 5 * 60 * 1000;
const SCHEDULE_STALE_MS = 60 * 1000;
const GITHUB_WORKFLOW_DISPATCH_URL =
"https://api.github.com/repos/openclaw/clawhub/actions/workflows/security-scan-codex.yml/dispatches";
const GITHUB_REPOSITORY_DISPATCH_URL = "https://api.github.com/repos/openclaw/clawhub/dispatches";
const internalRefs = internal as unknown as {
securityScanDispatch: {
@@ -38,6 +37,14 @@ export function isSecurityScanEventDispatchEnabled(env: NodeJS.ProcessEnv = proc
);
}
export function getGitHubRepositoryDispatchPermission(permissions: Record<string, string>) {
const contentsPermission = permissions.contents ?? "none";
return {
contentsPermission,
canDispatch: contentsPermission === "write",
};
}
export async function dispatchSecurityScanWorkflow(
installationToken: {
token: string;
@@ -45,11 +52,11 @@ export async function dispatchSecurityScanWorkflow(
},
fetchImpl: typeof fetch = fetch,
) {
if (installationToken.permissions.actions !== "write") {
return { ok: false as const, reason: "actions-write-required" as const };
if (!getGitHubRepositoryDispatchPermission(installationToken.permissions).canDispatch) {
return { ok: false as const, reason: "contents-write-required" as const };
}
const response = await fetchImpl(GITHUB_WORKFLOW_DISPATCH_URL, {
const response = await fetchImpl(GITHUB_REPOSITORY_DISPATCH_URL, {
method: "POST",
headers: {
Accept: "application/vnd.github+json",
@@ -59,10 +66,10 @@ export async function dispatchSecurityScanWorkflow(
"X-GitHub-Api-Version": "2022-11-28",
},
body: JSON.stringify({
ref: "main",
inputs: {
"batch-limit": "4",
"max-runtime-minutes": "8",
event_type: "clawhub-security-scan",
client_payload: {
batch_limit: "4",
max_runtime_minutes: "8",
},
}),
});
@@ -210,24 +217,23 @@ export const finishSecurityScanDispatchInternal = internalMutation({
},
});
export const checkGitHubActionsPermissionInternal = internalAction({
export const checkGitHubRepositoryDispatchPermissionInternal = internalAction({
args: {},
handler: async () => {
if (!isGitHubAppConfigured()) {
return {
configured: false as const,
actionsPermission: null,
contentsPermission: null,
canDispatch: false,
};
}
const installationToken = await createGitHubAppInstallationToken({
userAgent: "clawhub/security-scan-dispatch-preflight",
});
const actionsPermission = installationToken.permissions.actions ?? "none";
const permission = getGitHubRepositoryDispatchPermission(installationToken.permissions);
return {
configured: true as const,
actionsPermission,
canDispatch: actionsPermission === "write",
...permission,
};
},
});
@@ -264,9 +270,9 @@ export const dispatchSecurityScanWorkerInternal = internalAction({
}
const error =
result.reason === "actions-write-required"
? "GitHub App Actions write permission is required"
: `GitHub workflow dispatch rejected with HTTP ${result.status}`;
result.reason === "contents-write-required"
? "GitHub App Contents write permission is required"
: `GitHub repository dispatch rejected with HTTP ${result.status}`;
await runMutationRef(
ctx,
internalRefs.securityScanDispatch.finishSecurityScanDispatchInternal,
@@ -41,6 +41,7 @@ describe("security-scan-codex workflow", () => {
};
};
on?: {
repository_dispatch?: { types?: string[] };
schedule?: Array<{ cron?: string }>;
workflow_dispatch?: unknown;
};
@@ -69,11 +70,18 @@ describe("security-scan-codex workflow", () => {
expect(uploadStep?.with?.path).toBe("${{ env.CODEX_SECURITY_SCAN_DIAGNOSTICS_DIR }}");
expect(workflow.jobs["codex-security-scan"]["timeout-minutes"]).toBe(20);
expect(workflow.on?.workflow_dispatch).toBeDefined();
expect(workflow.on?.repository_dispatch?.types).toEqual(["clawhub-security-scan"]);
expect(workflow.on?.schedule).toBeUndefined();
expect(workflow.jobs["codex-security-scan"].strategy?.["max-parallel"]).toBe(4);
expect(workflow.jobs["codex-security-scan"].strategy?.matrix?.shard).toEqual([0, 1, 2, 3]);
expect(jobEnv.CODEX_SECURITY_SCAN_LIMIT).toBe(
"${{ github.event.client_payload.batch_limit || inputs.limit || inputs['batch-limit'] || '4' }}",
);
expect(jobEnv.CODEX_SECURITY_SCAN_MAX_JOBS).toBe(
"${{ github.event.client_payload.max_jobs || inputs['max-jobs'] || '' }}",
);
expect(jobEnv.CODEX_SECURITY_SCAN_MAX_RUNTIME_MINUTES).toBe(
"${{ inputs['max-runtime-minutes'] || '8' }}",
"${{ github.event.client_payload.max_runtime_minutes || inputs['max-runtime-minutes'] || '8' }}",
);
expect(jobEnv.CODEX_SECURITY_SCAN_TIMEOUT_MS).toBe(
"${{ vars.CODEX_SECURITY_SCAN_TIMEOUT_MS || '240000' }}",
+4 -2
View File
@@ -247,8 +247,10 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic
- Claimable queue work edge-triggers a coalesced GitHub Actions worker dispatch.
Successful completion requests another dispatch while queued work remains; a
five-minute Convex cron is only a recovery watchdog for lost dispatch signals.
Event-driven dispatch stays disabled until the production GitHub App is
verified to have Actions write permission.
Convex emits the narrowly typed `clawhub-security-scan`
`repository_dispatch` event using the production GitHub App. Event-driven
dispatch stays disabled until that installation is verified to have Contents
write permission.
- Queue source priority is `manual`, `backfill`, `publish`, `vt-update`, then
`bulk-rescan`. A later VirusTotal update may make a waiting publish job
claimable immediately, but it must not demote that job from publish priority.