fix: gate local Codex workers (#2472)

This commit is contained in:
Vyctor H. Brzezowski
2026-06-03 12:26:20 -07:00
committed by GitHub
parent 858a121d33
commit 74aa61086e
9 changed files with 182 additions and 3 deletions
+18
View File
@@ -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:
+31
View File
@@ -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;
}
+30 -3
View File
@@ -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"]);
+11
View File
@@ -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;
@@ -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(
{
@@ -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");
@@ -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);
@@ -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();
+8
View File
@@ -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.