From 74aa61086e6919f33a2fbee9e020552fe9e1c3c5 Mon Sep 17 00:00:00 2001 From: "Vyctor H. Brzezowski" Date: Wed, 3 Jun 2026 16:26:20 -0300 Subject: [PATCH] fix: gate local Codex workers (#2472) --- CONTRIBUTING.md | 18 +++++++ scripts/codex-worker-guard.ts | 31 +++++++++++ scripts/dev-workers.test.ts | 33 ++++++++++-- scripts/dev-workers.ts | 11 ++++ .../security/run-codex-scan-worker.test.ts | 54 +++++++++++++++++++ scripts/security/run-codex-scan-worker.ts | 9 ++++ .../skill-cards/run-skill-card-worker.test.ts | 12 +++++ scripts/skill-cards/run-skill-card-worker.ts | 9 ++++ specs/dev-worktrees.md | 8 +++ 9 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 scripts/codex-worker-guard.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6227e841..14466357 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -103,6 +103,24 @@ CLAWHUB_WORKTREE_SOURCE=/path/to/source/worktree bun run setup:worktree The detached server writes runtime state under `.codex/runtime/`. Stop it with `wt --yes stop` before removing the worktree. +### Local Codex workers + +Local dev does not start Codex-backed workers by default, so `dev:worktree` does +not spend Codex quota. + +To process local ClawScan or Skill Card jobs, opt in for that shell: + +```bash +CLAWHUB_ALLOW_LOCAL_CODEX_SCAN=1 bun run dev:workers -- --workers security-scan --once +CLAWHUB_ALLOW_LOCAL_CODEX_SCAN=1 bun run dev:workers -- --workers skill-card --once +``` + +Opted-in local runs use an ignored worktree-local `CODEX_HOME` unless you provide +one. + +Without those workers, local ClawScan and Skill Card jobs stay pending until you +opt in, seed/mock results, or use the production workflows. + ### Seed the database Populate local QA fixtures and the committed public corpus so the UI isn't empty: diff --git a/scripts/codex-worker-guard.ts b/scripts/codex-worker-guard.ts new file mode 100644 index 00000000..f0c7daa7 --- /dev/null +++ b/scripts/codex-worker-guard.ts @@ -0,0 +1,31 @@ +export const LOCAL_CODEX_WORKER_OPT_IN = "CLAWHUB_ALLOW_LOCAL_CODEX_SCAN"; + +export function isGitHubActionsRunner(env: NodeJS.ProcessEnv) { + return ( + env.GITHUB_ACTIONS === "true" && + env.CI === "true" && + Boolean(env.GITHUB_RUN_ID?.trim()) && + Boolean(env.GITHUB_REPOSITORY?.trim()) + ); +} + +export function isCodexWorkerExecutionAllowed(env: NodeJS.ProcessEnv) { + return env[LOCAL_CODEX_WORKER_OPT_IN] === "1" || isGitHubActionsRunner(env); +} + +export function localCodexWorkerOptInReason() { + return `set ${LOCAL_CODEX_WORKER_OPT_IN}=1 to run Codex workers locally`; +} + +export function assertCodexWorkerExecutionAllowed(env: NodeJS.ProcessEnv) { + if (isCodexWorkerExecutionAllowed(env)) return; + throw new Error(`Refusing to run local Codex workers without ${LOCAL_CODEX_WORKER_OPT_IN}=1`); +} + +export function resolveCodexWorkerHome(env: NodeJS.ProcessEnv, fallbackLocalHome: string) { + const explicitHome = env.CODEX_HOME?.trim(); + if (explicitHome) return explicitHome; + if (isGitHubActionsRunner(env)) return undefined; + if (env[LOCAL_CODEX_WORKER_OPT_IN] === "1") return fallbackLocalHome; + return undefined; +} diff --git a/scripts/dev-workers.test.ts b/scripts/dev-workers.test.ts index 476326f4..5c83e284 100644 --- a/scripts/dev-workers.test.ts +++ b/scripts/dev-workers.test.ts @@ -110,15 +110,42 @@ describe("dev-workers", () => { env: {}, }); - expect(resolved.workers.map((worker) => worker.id)).toEqual(["security-scan"]); + expect(resolved.workers.map((worker) => worker.id)).toEqual([]); expect(resolved.skipped).toEqual([ + expect.objectContaining({ + workerId: "security-scan", + reason: expect.stringContaining("CLAWHUB_ALLOW_LOCAL_CODEX_SCAN=1"), + }), expect.objectContaining({ workerId: "skill-card", - reason: expect.stringContaining("NVIDIA Skill Card automation checkout"), + reason: expect.stringContaining("CLAWHUB_ALLOW_LOCAL_CODEX_SCAN=1"), }), ]); }); + it("keeps Codex workers with explicit local opt-in", async () => { + const root = await tempDir(); + const toolDir = join(root, "nvidia-tooling"); + await mkdir(join(toolDir, "AI Transparency Card Automation", "scripts"), { recursive: true }); + await writeFile( + join(toolDir, "AI Transparency Card Automation", "scripts", "render_card.py"), + "print('render')\n", + "utf8", + ); + const selected = resolveEnabledWorkers({ + workers: [], + skip: [], + }); + + const resolved = resolveRunnableWorkers(selected, parseArgs(["--nvidia-tool-dir", toolDir]), { + cwd: root, + env: { CLAWHUB_ALLOW_LOCAL_CODEX_SCAN: "1" }, + }); + + expect(resolved.workers.map((worker) => worker.id)).toEqual(["security-scan", "skill-card"]); + expect(resolved.skipped).toEqual([]); + }); + it("keeps the Skill Card worker when an explicit NVIDIA tool checkout exists", async () => { const root = await tempDir(); const toolDir = join(root, "nvidia-tooling"); @@ -136,7 +163,7 @@ describe("dev-workers", () => { const resolved = resolveRunnableWorkers( selected, parseArgs(["--workers", "skill-card", "--nvidia-tool-dir", toolDir]), - { cwd: root, env: {} }, + { cwd: root, env: { CLAWHUB_ALLOW_LOCAL_CODEX_SCAN: "1" } }, ); expect(resolved.workers.map((worker) => worker.id)).toEqual(["skill-card"]); diff --git a/scripts/dev-workers.ts b/scripts/dev-workers.ts index 78b4c4a2..d612f564 100644 --- a/scripts/dev-workers.ts +++ b/scripts/dev-workers.ts @@ -4,6 +4,7 @@ import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { basename, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; +import { isCodexWorkerExecutionAllowed, localCodexWorkerOptInReason } from "./codex-worker-guard"; type DevWorkerId = "security-scan" | "skill-card"; @@ -13,6 +14,7 @@ type WorkerDefinition = { script: string; requiredEnv: string[]; requiredAnyEnv?: string[]; + requiresCodexCli: boolean; productionWorkflow: string; }; @@ -36,6 +38,7 @@ export const WORKERS: WorkerDefinition[] = [ script: "scripts/security/run-codex-scan-worker.ts", // Shared Convex worker credential used by security and Skill Card workers. requiredEnv: ["CONVEX_URL", "SECURITY_SCAN_WORKER_TOKEN"], + requiresCodexCli: true, productionWorkflow: ".github/workflows/security-scan-codex.yml", }, { @@ -44,6 +47,7 @@ export const WORKERS: WorkerDefinition[] = [ script: "scripts/skill-cards/run-skill-card-worker.ts", // Shared Convex worker credential used by security and Skill Card workers. requiredEnv: ["CONVEX_URL", "SECURITY_SCAN_WORKER_TOKEN"], + requiresCodexCli: true, productionWorkflow: ".github/workflows/skill-card-worker.yml", }, ]; @@ -204,6 +208,13 @@ export function resolveRunnableWorkers( const runnable: WorkerDefinition[] = []; const skipped: Array<{ workerId: DevWorkerId; reason: string }> = []; for (const worker of workers) { + if (worker.requiresCodexCli && !isCodexWorkerExecutionAllowed(context.env)) { + skipped.push({ + workerId: worker.id, + reason: localCodexWorkerOptInReason(), + }); + continue; + } if (worker.id !== "skill-card" || hasNvidiaSkillCardTooling(options, context)) { runnable.push(worker); continue; diff --git a/scripts/security/run-codex-scan-worker.test.ts b/scripts/security/run-codex-scan-worker.test.ts index 947d2ba2..4d9ded09 100644 --- a/scripts/security/run-codex-scan-worker.test.ts +++ b/scripts/security/run-codex-scan-worker.test.ts @@ -3,6 +3,12 @@ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promis import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { + assertCodexWorkerExecutionAllowed, + isCodexWorkerExecutionAllowed, + LOCAL_CODEX_WORKER_OPT_IN, + resolveCodexWorkerHome, +} from "../codex-worker-guard"; import { buildPrompt, normalizeSkillSpectorAnalysis, @@ -24,6 +30,54 @@ async function tempDir() { } describe("run-codex-scan-worker diagnostics", () => { + it("blocks direct local Codex security worker runs without opt-in", () => { + expect(isCodexWorkerExecutionAllowed({})).toBe(false); + expect(() => assertCodexWorkerExecutionAllowed({})).toThrow( + `Refusing to run local Codex workers without ${LOCAL_CODEX_WORKER_OPT_IN}=1`, + ); + }); + + it("does not treat a bare GITHUB_ACTIONS flag as CI authorization", () => { + expect(isCodexWorkerExecutionAllowed({ GITHUB_ACTIONS: "true" })).toBe(false); + expect(() => assertCodexWorkerExecutionAllowed({ GITHUB_ACTIONS: "true" })).toThrow( + `Refusing to run local Codex workers without ${LOCAL_CODEX_WORKER_OPT_IN}=1`, + ); + }); + + it("allows direct Codex security worker runs in GitHub Actions", () => { + const env = { + CI: "true", + GITHUB_ACTIONS: "true", + GITHUB_REPOSITORY: "openclaw/clawhub", + GITHUB_RUN_ID: "123", + }; + + expect(isCodexWorkerExecutionAllowed(env)).toBe(true); + expect(() => assertCodexWorkerExecutionAllowed(env)).not.toThrow(); + }); + + it("allows direct local Codex security worker runs with explicit opt-in", () => { + expect(isCodexWorkerExecutionAllowed({ [LOCAL_CODEX_WORKER_OPT_IN]: "1" })).toBe(true); + expect(() => + assertCodexWorkerExecutionAllowed({ [LOCAL_CODEX_WORKER_OPT_IN]: "1" }), + ).not.toThrow(); + }); + + it("uses an isolated local Codex home for opted-in local workers by default", () => { + expect( + resolveCodexWorkerHome( + { [LOCAL_CODEX_WORKER_OPT_IN]: "1" }, + "/repo/.codex/runtime/codex-workers/security-scan", + ), + ).toBe("/repo/.codex/runtime/codex-workers/security-scan"); + expect( + resolveCodexWorkerHome( + { [LOCAL_CODEX_WORKER_OPT_IN]: "1", CODEX_HOME: "/tmp/custom-codex-home" }, + "/repo/.codex/runtime/codex-workers/security-scan", + ), + ).toBe("/tmp/custom-codex-home"); + }); + it("frames workspace inspection as discretionary Codex research", () => { const prompt = buildPrompt( { diff --git a/scripts/security/run-codex-scan-worker.ts b/scripts/security/run-codex-scan-worker.ts index 16c2e6d5..991cbc5a 100644 --- a/scripts/security/run-codex-scan-worker.ts +++ b/scripts/security/run-codex-scan-worker.ts @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { mkdirSync } from "node:fs"; import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; @@ -12,6 +13,7 @@ import { type LlmEvalDimension, SKILL_SECURITY_EVALUATOR_SYSTEM_PROMPT, } from "../../convex/lib/securityPrompt"; +import { assertCodexWorkerExecutionAllowed, resolveCodexWorkerHome } from "../codex-worker-guard"; type ClaimedJob = { job: { @@ -113,6 +115,7 @@ const DEFAULT_DIAGNOSTICS_ROOT = join( ".artifacts/codex-security-scan", process.env.GITHUB_RUN_ID ?? `local-${process.pid}`, ); +const LOCAL_CODEX_HOME = join(root, ".codex/runtime/codex-workers/security-scan"); const ARTIFACT_SIGNAL_FILE_EXTENSIONS = new Set([ ".cjs", ".css", @@ -601,6 +604,11 @@ Return the required JSON object only.`; function codexEnv() { const env = { ...process.env }; + const codexHome = resolveCodexWorkerHome(process.env, LOCAL_CODEX_HOME); + if (codexHome) { + mkdirSync(codexHome, { recursive: true }); + env.CODEX_HOME = codexHome; + } delete env.GH_TOKEN; delete env.GITHUB_TOKEN; delete env.CONVEX_DEPLOY_KEY; @@ -1081,6 +1089,7 @@ async function processJob( async function main() { const { batchLimit, maxJobs, maxRuntimeMs, leaseMs, diagnosticsRoot } = parseArgs(); + assertCodexWorkerExecutionAllowed(process.env); const convexUrl = process.env.CONVEX_URL ?? process.env.VITE_CONVEX_URL; if (!convexUrl) throw new Error("CONVEX_URL or VITE_CONVEX_URL is required"); const token = requireEnv("SECURITY_SCAN_WORKER_TOKEN"); diff --git a/scripts/skill-cards/run-skill-card-worker.test.ts b/scripts/skill-cards/run-skill-card-worker.test.ts index 6f02d05b..72305583 100644 --- a/scripts/skill-cards/run-skill-card-worker.test.ts +++ b/scripts/skill-cards/run-skill-card-worker.test.ts @@ -3,6 +3,11 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { + assertCodexWorkerExecutionAllowed, + isCodexWorkerExecutionAllowed, + LOCAL_CODEX_WORKER_OPT_IN, +} from "../codex-worker-guard"; import { applyServerPublisherToContext, assertPublicSkillCardMarkdown, @@ -29,6 +34,13 @@ async function tempDir() { } describe("run-skill-card-worker Codex skill setup", () => { + it("blocks direct local Skill Card worker runs without Codex opt-in", () => { + expect(isCodexWorkerExecutionAllowed({})).toBe(false); + expect(() => assertCodexWorkerExecutionAllowed({})).toThrow( + `Refusing to run local Codex workers without ${LOCAL_CODEX_WORKER_OPT_IN}=1`, + ); + }); + it("uses the same batch, runtime, and lease defaults as the security worker", () => { expect(DEFAULT_BATCH_LIMIT).toBe(6); expect(DEFAULT_MAX_RUNTIME_MS).toBe(40 * 60 * 1000); diff --git a/scripts/skill-cards/run-skill-card-worker.ts b/scripts/skill-cards/run-skill-card-worker.ts index cb628277..1c3bbf9c 100644 --- a/scripts/skill-cards/run-skill-card-worker.ts +++ b/scripts/skill-cards/run-skill-card-worker.ts @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { mkdirSync } from "node:fs"; import { cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; @@ -6,6 +7,7 @@ import { pathToFileURL } from "node:url"; import { ConvexHttpClient } from "convex/browser"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; +import { assertCodexWorkerExecutionAllowed, resolveCodexWorkerHome } from "../codex-worker-guard"; type ClaimedSkillCardJob = { job: { @@ -44,6 +46,7 @@ const NVIDIA_AUTOMATION_DIR = "AI Transparency Card Automation"; const NVIDIA_SKILL_DIR = "nvidia-skill-card-generator"; const SKILL_CARD_CONTEXT_FILE = "skill-card.context.json"; const SKILL_CARD_OUTPUT_FILE = "skill-card.md"; +const LOCAL_CODEX_HOME = join(root, ".codex/runtime/codex-workers/skill-card"); const NVIDIA_ONLY_PUBLIC_CARD_PATTERNS = [ "NVIDIA believes", "For Release on NVIDIA Platforms Only", @@ -137,6 +140,11 @@ async function download(url: string) { function codexEnv() { const env = { ...process.env }; + const codexHome = resolveCodexWorkerHome(process.env, LOCAL_CODEX_HOME); + if (codexHome) { + mkdirSync(codexHome, { recursive: true }); + env.CODEX_HOME = codexHome; + } delete env.GH_TOKEN; delete env.GITHUB_TOKEN; delete env.CONVEX_DEPLOY_KEY; @@ -442,6 +450,7 @@ async function processJob( async function main() { const { batchLimit, maxJobs, maxRuntimeMs, leaseMs, toolDir } = parseArgs(); + assertCodexWorkerExecutionAllowed(process.env); const convexUrl = process.env.CONVEX_URL ?? process.env.VITE_CONVEX_URL; if (!convexUrl) throw new Error("CONVEX_URL or VITE_CONVEX_URL is required"); const token = workerToken(); diff --git a/specs/dev-worktrees.md b/specs/dev-worktrees.md index 3babe945..b4317d52 100644 --- a/specs/dev-worktrees.md +++ b/specs/dev-worktrees.md @@ -65,6 +65,14 @@ The copy step is best effort. If `.convex` is already a symlink to the source wo Use `wt --yes stop` before removing or recreating a worktree. If a stale pid blocks startup, stop the service and inspect the runtime log before deleting files by hand. +Local worktree startup must not consume the developer's Codex account +implicitly. Workers that invoke Codex CLI, including ClawScan and Skill Card +generation, are disabled in local dev unless the process has +`CLAWHUB_ALLOW_LOCAL_CODEX_SCAN=1`; GitHub Actions workers remain allowed. When +local Codex workers are explicitly enabled, they must default to an ignored +worktree-local `CODEX_HOME` under `.codex/runtime/codex-workers/` unless the +operator provides `CODEX_HOME`. + ## Seeding Contract `bun run seed:dev` uses the same worktree setup helper and the same local Convex readiness checks as the detached dev server. It must remain the documented default seed command. Lower-level Convex calls and `seed:public-corpus` are recovery or fixture-authoring tools, not the first-run path.