fix(testbox): guard stale OpenClaw Testbox reuse

This commit is contained in:
Vincent Koc
2026-04-29 00:50:48 -07:00
parent 1e168b17b7
commit bd3ffd0802
9 changed files with 604 additions and 5 deletions

View File

@@ -0,0 +1,122 @@
#!/usr/bin/env node
import { execFileSync, spawnSync } from "node:child_process";
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
evaluateLocalTestboxKey,
evaluateOpenClawTestboxClaim,
resolveTestboxId,
writeOpenClawTestboxClaim,
} from "./blacksmith-testbox-state.mjs";
function git(args, cwd) {
return execFileSync("git", args, { cwd, encoding: "utf8" });
}
export function splitRunnerArgs(argv = []) {
const separatorIndex = argv.indexOf("--");
if (separatorIndex === -1) {
return { runnerArgs: argv, commandArgs: [] };
}
return {
runnerArgs: argv.slice(0, separatorIndex),
commandArgs: argv.slice(separatorIndex + 1),
};
}
export function buildBlacksmithRunArgs({ commandArgs, testboxId }) {
const command = commandArgs.join(" ").trim();
if (!command) {
return [];
}
return ["testbox", "run", "--id", testboxId, command];
}
function hasClaimFlag(runnerArgs) {
return runnerArgs.includes("--claim") || runnerArgs.includes("--claim-fresh");
}
function stripRunnerOnlyFlags(runnerArgs) {
return runnerArgs.filter((arg) => arg !== "--claim" && arg !== "--claim-fresh");
}
export function runBlacksmithTestboxRunner({
argv = process.argv.slice(2),
cwd = process.cwd(),
env = process.env,
spawn = spawnSync,
stderr = process.stderr,
stdout = process.stdout,
} = {}) {
const { runnerArgs, commandArgs } = splitRunnerArgs(argv);
const shouldClaim = hasClaimFlag(runnerArgs);
const testboxId = resolveTestboxId({ argv: stripRunnerOnlyFlags(runnerArgs), env });
if (!testboxId) {
stderr.write(
"Missing Testbox id. Pass `--id <tbx_id>` or set OPENCLAW_TESTBOX_ID from this session's warmup output.\n",
);
return 2;
}
const keyResult = evaluateLocalTestboxKey({ env, testboxId });
if (!keyResult.ok) {
stderr.write(`${keyResult.problems.join("\n")}\n`);
stderr.write(
"Refusing to reuse a remote-visible Testbox without the local private key. Run:\n" +
" blacksmith testbox warmup ci-check-testbox.yml --ref main --idle-timeout 90\n",
);
return 2;
}
const root = git(["rev-parse", "--show-toplevel"], cwd).trim();
if (path.resolve(cwd) !== path.resolve(root)) {
stderr.write(
`Refusing to run Testbox sync from ${cwd}; run from repo root ${root} so rsync does not mirror a subdirectory.\n`,
);
return 2;
}
if (shouldClaim) {
const claim = writeOpenClawTestboxClaim({ cwd: root, env, testboxId });
stdout.write(`OpenClaw Testbox claim written: ${testboxId} -> ${claim.claimPath}\n`);
} else {
const claimResult = evaluateOpenClawTestboxClaim({
cwd: root,
env,
testboxId,
});
if (!claimResult.ok) {
stderr.write(`${claimResult.problems.join("\n")}\n`);
stderr.write(
"Refusing to run a Testbox that was not claimed by this OpenClaw checkout. Run:\n" +
" blacksmith testbox warmup ci-check-testbox.yml --ref main --idle-timeout 90\n" +
" pnpm testbox:claim --id <new_tbx_id>\n",
);
return 2;
}
}
const blacksmithArgs = buildBlacksmithRunArgs({ commandArgs, testboxId });
if (blacksmithArgs.length === 0) {
stdout.write(`Testbox local key and OpenClaw claim ok: ${testboxId}\n`);
return 0;
}
const result = spawn("blacksmith", blacksmithArgs, {
cwd,
env,
stdio: "inherit",
});
if (typeof result.status === "number") {
return result.status;
}
if (result.error) {
stderr.write(`Failed to start blacksmith: ${result.error.message}\n`);
}
return 1;
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
process.exitCode = runBlacksmithTestboxRunner();
}

View File

@@ -0,0 +1,194 @@
import fs from "node:fs";
import path from "node:path";
const DEFAULT_OPENCLAW_TESTBOX_CLAIM_TTL_MINUTES = 12 * 60;
const TESTBOX_ID_PATTERN = /^tbx_[a-z0-9]+$/u;
const OPENCLAW_TESTBOX_CLAIM_FILE = "openclaw-runner.json";
function parsePositiveInteger(value, fallback) {
if (!value) {
return fallback;
}
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
export function parseTestboxIdArg(argv = []) {
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index];
if (value === "--id" || value === "--testbox-id") {
return argv[index + 1] ?? "";
}
if (value?.startsWith("--id=")) {
return value.slice("--id=".length);
}
if (value?.startsWith("--testbox-id=")) {
return value.slice("--testbox-id=".length);
}
}
return "";
}
export function resolveTestboxId({ argv = [], env = process.env } = {}) {
return (
parseTestboxIdArg(argv) ||
env.OPENCLAW_TESTBOX_ID ||
env.BLACKSMITH_TESTBOX_ID ||
env.TESTBOX_ID ||
""
).trim();
}
export function resolveBlacksmithTestboxStateDir({ env = process.env, homeDir } = {}) {
if (env.OPENCLAW_BLACKSMITH_TESTBOX_STATE_DIR) {
return env.OPENCLAW_BLACKSMITH_TESTBOX_STATE_DIR;
}
const blacksmithHome =
env.BLACKSMITH_HOME || path.join(homeDir || env.HOME || process.cwd(), ".blacksmith");
return path.join(blacksmithHome, "testboxes");
}
export function evaluateLocalTestboxKey({
testboxId,
env = process.env,
exists = fs.existsSync,
homeDir,
} = {}) {
if (!testboxId) {
return { ok: true, checked: false, problems: [] };
}
const problems = [];
if (!TESTBOX_ID_PATTERN.test(testboxId)) {
problems.push(`invalid Testbox id: ${testboxId}`);
return {
ok: false,
checked: true,
keyPath: "",
problems,
testboxId,
};
}
const stateDir = resolveBlacksmithTestboxStateDir({ env, homeDir });
const testboxDir = path.join(stateDir, testboxId);
const keyPath = path.join(testboxDir, "id_ed25519");
if (!exists(keyPath)) {
problems.push(
`local Testbox SSH key missing for ${testboxId}: expected ${keyPath}. ` +
"This id may be visible in `blacksmith testbox list` but unusable by this operator; warm a fresh box instead.",
);
}
return {
ok: problems.length === 0,
checked: true,
keyPath,
problems,
testboxDir,
testboxId,
};
}
export function resolveOpenClawTestboxClaimPath({ testboxId, env = process.env, homeDir } = {}) {
const stateDir = resolveBlacksmithTestboxStateDir({ env, homeDir });
return path.join(stateDir, testboxId, OPENCLAW_TESTBOX_CLAIM_FILE);
}
export function evaluateOpenClawTestboxClaim({
testboxId,
cwd,
env = process.env,
exists = fs.existsSync,
now = () => new Date(),
readFile = fs.readFileSync,
homeDir,
} = {}) {
if (!testboxId) {
return { ok: true, checked: false, problems: [] };
}
const claimPath = resolveOpenClawTestboxClaimPath({ testboxId, env, homeDir });
const expectedRepoRoot = path.resolve(cwd || process.cwd());
const maxAgeMinutes = parsePositiveInteger(
env.OPENCLAW_TESTBOX_CLAIM_TTL_MINUTES,
DEFAULT_OPENCLAW_TESTBOX_CLAIM_TTL_MINUTES,
);
const problems = [];
if (!exists(claimPath)) {
problems.push(
`OpenClaw Testbox claim missing for ${testboxId}: expected ${claimPath}. ` +
"Do not reuse ids from `blacksmith testbox list`; warm a fresh box and claim it with `pnpm testbox:claim --id <id>`.",
);
return {
ok: false,
checked: true,
claimPath,
expectedRepoRoot,
problems,
testboxId,
};
}
let claim;
try {
claim = JSON.parse(readFile(claimPath, "utf8"));
} catch (error) {
problems.push(`OpenClaw Testbox claim is unreadable for ${testboxId}: ${error.message}`);
}
const claimedRepoRoot = claim?.repoRoot ? path.resolve(claim.repoRoot) : "";
if (!claimedRepoRoot) {
problems.push(`OpenClaw Testbox claim is missing repoRoot for ${testboxId}: ${claimPath}`);
} else if (claimedRepoRoot !== expectedRepoRoot) {
problems.push(
`OpenClaw Testbox claim repo mismatch for ${testboxId}: claimed ${claimedRepoRoot}, current ${expectedRepoRoot}. ` +
"Warm and claim a fresh box for this checkout.",
);
}
const claimedAtMs = Date.parse(claim?.claimedAt ?? "");
if (!Number.isFinite(claimedAtMs)) {
problems.push(`OpenClaw Testbox claim is missing claimedAt for ${testboxId}: ${claimPath}`);
} else {
const ageMinutes = Math.floor((now().getTime() - claimedAtMs) / 60000);
if (ageMinutes > maxAgeMinutes) {
problems.push(
`OpenClaw Testbox claim is stale for ${testboxId}: ${ageMinutes}m old, limit ${maxAgeMinutes}m. ` +
"Warm and claim a fresh box after crashes or long pauses.",
);
}
}
return {
ok: problems.length === 0,
checked: true,
claim,
claimPath,
expectedRepoRoot,
problems,
testboxId,
};
}
export function writeOpenClawTestboxClaim({
testboxId,
cwd,
env = process.env,
homeDir,
mkdir = fs.mkdirSync,
writeFile = fs.writeFileSync,
now = () => new Date(),
} = {}) {
const claimPath = resolveOpenClawTestboxClaimPath({ testboxId, env, homeDir });
const repoRoot = path.resolve(cwd || process.cwd());
const payload = {
claimedAt: now().toISOString(),
repoRoot,
runnerVersion: 1,
};
mkdir(path.dirname(claimPath), { recursive: true });
writeFile(claimPath, `${JSON.stringify(payload, null, 2)}\n`);
return { claimPath, payload, testboxId };
}

View File

@@ -267,6 +267,8 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
["scripts/test-projects.mjs", ["test/scripts/test-projects.test.ts"]],
["scripts/test-projects.test-support.d.mts", ["test/scripts/test-projects.test.ts"]],
["scripts/test-projects.test-support.mjs", ["test/scripts/test-projects.test.ts"]],
["scripts/blacksmith-testbox-state.mjs", ["test/scripts/blacksmith-testbox-state.test.ts"]],
["scripts/blacksmith-testbox-runner.mjs", ["test/scripts/blacksmith-testbox-runner.test.ts"]],
["scripts/testbox-sync-sanity.mjs", ["test/scripts/testbox-sync-sanity.test.ts"]],
]);
const TOOLING_TEST_TARGETS = new Map([
@@ -279,6 +281,14 @@ const TOOLING_TEST_TARGETS = new Map([
["test/scripts/plugin-prerelease-test-plan.test.ts"],
],
["test/scripts/test-projects.test.ts", ["test/scripts/test-projects.test.ts"]],
[
"test/scripts/blacksmith-testbox-runner.test.ts",
["test/scripts/blacksmith-testbox-runner.test.ts"],
],
[
"test/scripts/blacksmith-testbox-state.test.ts",
["test/scripts/blacksmith-testbox-state.test.ts"],
],
["test/scripts/testbox-sync-sanity.test.ts", ["test/scripts/testbox-sync-sanity.test.ts"]],
[
"test/scripts/vitest-local-scheduling.test.ts",

View File

@@ -4,6 +4,11 @@ import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
evaluateLocalTestboxKey,
evaluateOpenClawTestboxClaim,
resolveTestboxId,
} from "./blacksmith-testbox-state.mjs";
const DEFAULT_DELETION_THRESHOLD = 200;
const REQUIRED_ROOT_FILES = ["package.json", "pnpm-lock.yaml", ".gitignore"];
@@ -78,11 +83,22 @@ function git(args, cwd) {
export function runTestboxSyncSanity({
cwd = process.cwd(),
env = process.env,
argv = process.argv.slice(2),
stdout = process.stdout,
stderr = process.stderr,
} = {}) {
const root = git(["rev-parse", "--show-toplevel"], cwd).trim();
const statusRaw = git(["status", "--short", "--untracked-files=all"], root);
const testboxId = resolveTestboxId({ argv, env });
const keyResult = evaluateLocalTestboxKey({
env,
testboxId,
});
const claimResult = evaluateOpenClawTestboxClaim({
cwd: root,
env,
testboxId,
});
const result = evaluateTestboxSyncSanity({
cwd: root,
statusRaw,
@@ -92,13 +108,21 @@ export function runTestboxSyncSanity({
),
allowMassDeletions: parseBooleanEnv(env.OPENCLAW_TESTBOX_ALLOW_MASS_DELETIONS),
});
result.problems.push(...keyResult.problems);
result.problems.push(...claimResult.problems);
result.ok = result.problems.length === 0;
if (!result.ok) {
stderr.write(`Testbox sync sanity failed:\n- ${result.problems.join("\n- ")}\n`);
stderr.write("Warm a fresh box or rerun from a clean repo root before spending a gate.\n");
stderr.write(
"Warm a fresh box, keep using the id from this session, or rerun from a clean repo root before spending a gate.\n",
);
return 1;
}
if (keyResult.checked) {
stdout.write(`Testbox local key and OpenClaw claim ok: ${keyResult.testboxId}\n`);
}
stdout.write(
`Testbox sync sanity ok: ${result.statusEntryCount} changed entries, ${result.trackedDeletionCount} tracked deletions.\n`,
);