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

@@ -123,17 +123,22 @@ instantly and boots the CI environment in the background while you work:
blacksmith testbox warmup ci-check-testbox.yml
# → tbx_01jkz5b3t9...
Save this ID. You need it for every `run` command.
Save this ID in the current session. You need it for every `run` command.
Treat `blacksmith testbox list` as diagnostics, not a reusable work queue.
Listed boxes can be visible at the org/repo level while still being unusable or
stale for the current local agent lane.
For OpenClaw maintainer Testbox mode, pre-warm at the start of longer or wider
tasks:
blacksmith testbox warmup ci-check-testbox.yml --ref main --idle-timeout 90
pnpm testbox:claim --id <ID>
Use the build-artifact warmup when e2e/package/build proof benefits from seeded
`dist/`, `dist-runtime/`, and build-all caches:
blacksmith testbox warmup ci-build-artifacts-testbox.yml --ref main --idle-timeout 90
pnpm testbox:claim --id <ID>
Warmup dispatches a GitHub Actions workflow that provisions a VM with the
full CI environment: dependencies installed, services started, secrets
@@ -178,6 +183,26 @@ The `run` command automatically waits for the testbox to become ready if
it is still booting, so you can call `run` immediately after warmup without
needing to check status first.
In OpenClaw, prefer the guarded runner wrapper so stale/reused ids fail before
the Blacksmith CLI spends time syncing or emits a confusing missing-key error:
pnpm testbox:run --id <ID> -- "OPENCLAW_TESTBOX=1 pnpm check:changed"
The wrapper refuses to run when the local per-Testbox key is missing or when the
id was not claimed by this OpenClaw checkout with `pnpm testbox:claim --id
<ID>`. Treat that as the expected remediation, not as a GitHub account or
normal SSH-key problem. A local key alone is not enough; a ready box may still
carry stale rsync state from another lane.
If the agent crashes, the remote box relies on Blacksmith's idle timeout. The
local OpenClaw claim marker is not deleted automatically, so the wrapper treats
claims older than 12 hours as stale. Override only for intentional long-running
work with `OPENCLAW_TESTBOX_CLAIM_TTL_MINUTES=<minutes>`.
Before spending a broad gate on a manually assembled command, you can also run:
pnpm testbox:sanity -- --id <ID>
## Downloading files from a testbox
Use the `download` command to retrieve files or directories from a running
@@ -286,16 +311,17 @@ checks that need parity or remote state.
1. Decide whether the repo's local loop is the right default. For OpenClaw,
`OPENCLAW_TESTBOX=1` makes Testbox the maintainer default.
2. If Testbox is warranted, warm up early:
`blacksmith testbox warmup ci-check-testbox.yml --ref main --idle-timeout 90` → save the ID
`blacksmith testbox warmup ci-check-testbox.yml --ref main --idle-timeout 90` → save the ID,
then `pnpm testbox:claim --id <ID>`
3. Write code while the testbox boots in the background.
4. Run the remote command when needed:
`blacksmith testbox run --id <ID> "pnpm check:changed"`
`pnpm testbox:run --id <ID> -- "OPENCLAW_TESTBOX=1 pnpm check:changed"`
5. If tests fail, fix code and re-run against the same warm box.
6. If you changed dependency manifests (package.json, etc.), prepend
the install command: `blacksmith testbox run --id <ID> "npm install && npm test"`
7. If a narrow PR reports a full sync or the box was reused/expired, sanity
check the remote copy before a slow gate:
`blacksmith testbox run --id <ID> "pnpm testbox:sanity"`.
`pnpm testbox:run --id <ID> -- "pnpm testbox:sanity"`.
If it reports missing root files or mass tracked deletions, stop the box and
warm a fresh one. Use `OPENCLAW_TESTBOX_ALLOW_MASS_DELETIONS=1` only for an
intentional large deletion PR.

View File

@@ -36,6 +36,14 @@ Prove the touched surface first. Do not reflexively run the whole suite.
- Prefer GitHub Actions for release/Docker proof when the workflow already has the prepared image and secrets.
- Use `scripts/committer "<msg>" <paths...>` when committing; stage only your files.
- If deps are missing, run `pnpm install`, retry once, then report the first actionable error.
- For Blacksmith Testbox proof, reuse only an id warmed and claimed in this
operator session. `blacksmith testbox list` is diagnostics only; a listed id
can have a local key and still carry stale rsync state from another lane.
After warmup, run `pnpm testbox:claim --id <id>`, then prefer
`pnpm testbox:run --id <id> -- "<command>"` for OpenClaw gates so stale
org-visible ids fail fast before syncing. Claims older than 12 hours are
stale unless `OPENCLAW_TESTBOX_CLAIM_TTL_MINUTES` is explicitly set for long
work.
## Local Test Shortcuts

View File

@@ -1569,6 +1569,8 @@
"test:voicecall:closedloop": "node scripts/test-voicecall-closedloop.mjs",
"test:watch": "node scripts/test-projects.mjs --watch",
"test:windows:ci": "node scripts/test-projects.mjs src/shared/runtime-import.test.ts src/plugins/import-specifier.test.ts src/process/exec.windows.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts extensions/lobster/src/lobster-runner.test.ts test/scripts/npm-runner.test.ts test/scripts/pnpm-runner.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
"testbox:claim": "node scripts/blacksmith-testbox-runner.mjs --claim",
"testbox:run": "node scripts/blacksmith-testbox-runner.mjs",
"testbox:sanity": "node scripts/testbox-sync-sanity.mjs",
"tool-display:check": "node --import tsx scripts/tool-display.ts --check",
"tool-display:write": "node --import tsx scripts/tool-display.ts --write",

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`,
);

View File

@@ -0,0 +1,94 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
buildBlacksmithRunArgs,
runBlacksmithTestboxRunner,
splitRunnerArgs,
} from "../../scripts/blacksmith-testbox-runner.mjs";
describe("blacksmith testbox runner", () => {
it("splits runner args from the remote command", () => {
expect(
splitRunnerArgs(["--id", "tbx_abc123", "--", "OPENCLAW_TESTBOX=1", "pnpm", "check:changed"]),
).toEqual({
runnerArgs: ["--id", "tbx_abc123"],
commandArgs: ["OPENCLAW_TESTBOX=1", "pnpm", "check:changed"],
});
});
it("builds blacksmith run arguments", () => {
expect(
buildBlacksmithRunArgs({
commandArgs: ["OPENCLAW_TESTBOX=1", "pnpm", "check:changed"],
testboxId: "tbx_abc123",
}),
).toEqual(["testbox", "run", "--id", "tbx_abc123", "OPENCLAW_TESTBOX=1 pnpm check:changed"]);
});
it("refuses to run a remote-visible id without a local private key", () => {
let spawned = false;
const stderr = { write: (value: string) => value.length };
const code = runBlacksmithTestboxRunner({
argv: ["--id", "tbx_01kqap50t9fqggzw1akg5dtmmq", "--", "pnpm", "check:changed"],
env: { OPENCLAW_BLACKSMITH_TESTBOX_STATE_DIR: "/state/testboxes" },
spawn: () => {
spawned = true;
return { status: 0 };
},
stderr,
});
expect(code).toBe(2);
expect(spawned).toBe(false);
});
it("refuses to run a keyed id that was not claimed by this checkout", () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-testbox-runner-"));
const testboxDir = path.join(stateDir, "tbx_01kqap50t9fqggzw1akg5dtmmq");
fs.mkdirSync(testboxDir, { recursive: true });
fs.writeFileSync(path.join(testboxDir, "id_ed25519"), "test-key\n");
let spawned = false;
let stderrText = "";
const code = runBlacksmithTestboxRunner({
argv: ["--id", "tbx_01kqap50t9fqggzw1akg5dtmmq", "--", "pnpm", "check:changed"],
env: { ...process.env, OPENCLAW_BLACKSMITH_TESTBOX_STATE_DIR: stateDir },
spawn: () => {
spawned = true;
return { status: 0 };
},
stderr: { write: (value: string) => (stderrText += value) },
});
expect(code).toBe(2);
expect(spawned).toBe(false);
expect(stderrText).toContain("OpenClaw Testbox claim missing");
});
it("claims a keyed id without spawning when no remote command is supplied", () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-testbox-runner-"));
const testboxDir = path.join(stateDir, "tbx_01kqap50t9fqggzw1akg5dtmmq");
const claimPath = path.join(testboxDir, "openclaw-runner.json");
fs.mkdirSync(testboxDir, { recursive: true });
fs.writeFileSync(path.join(testboxDir, "id_ed25519"), "test-key\n");
let spawned = false;
let stdoutText = "";
const code = runBlacksmithTestboxRunner({
argv: ["--claim", "--id", "tbx_01kqap50t9fqggzw1akg5dtmmq"],
env: { ...process.env, OPENCLAW_BLACKSMITH_TESTBOX_STATE_DIR: stateDir },
spawn: () => {
spawned = true;
return { status: 0 };
},
stdout: { write: (value: string) => (stdoutText += value) },
});
expect(code).toBe(0);
expect(spawned).toBe(false);
expect(stdoutText).toContain("OpenClaw Testbox claim written");
expect(JSON.parse(fs.readFileSync(claimPath, "utf8")).repoRoot).toBe(process.cwd());
});
});

View File

@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import {
evaluateLocalTestboxKey,
evaluateOpenClawTestboxClaim,
parseTestboxIdArg,
resolveTestboxId,
writeOpenClawTestboxClaim,
} from "../../scripts/blacksmith-testbox-state.mjs";
describe("blacksmith testbox state", () => {
it("parses Testbox ids from args and env", () => {
expect(parseTestboxIdArg(["--id", "tbx_abc123"])).toBe("tbx_abc123");
expect(parseTestboxIdArg(["--testbox-id=tbx_def456"])).toBe("tbx_def456");
expect(resolveTestboxId({ argv: [], env: { OPENCLAW_TESTBOX_ID: "tbx_env123" } })).toBe(
"tbx_env123",
);
});
it("fails when a remote-visible Testbox id has no local private key", () => {
const result = evaluateLocalTestboxKey({
env: { OPENCLAW_BLACKSMITH_TESTBOX_STATE_DIR: "/state/testboxes" },
exists: () => false,
testboxId: "tbx_01kqap50t9fqggzw1akg5dtmmq",
});
expect(result.ok).toBe(false);
expect(result.keyPath).toBe("/state/testboxes/tbx_01kqap50t9fqggzw1akg5dtmmq/id_ed25519");
expect(result.problems[0]).toContain("local Testbox SSH key missing");
});
it("accepts a Testbox id with a local private key", () => {
const result = evaluateLocalTestboxKey({
env: { OPENCLAW_BLACKSMITH_TESTBOX_STATE_DIR: "/state/testboxes" },
exists: (file) => file.endsWith("/tbx_01kqap50t9fqggzw1akg5dtmmq/id_ed25519"),
testboxId: "tbx_01kqap50t9fqggzw1akg5dtmmq",
});
expect(result.ok).toBe(true);
expect(result.checked).toBe(true);
});
it("fails when a keyed Testbox id has no OpenClaw claim", () => {
const result = evaluateOpenClawTestboxClaim({
cwd: "/repo",
env: { OPENCLAW_BLACKSMITH_TESTBOX_STATE_DIR: "/state/testboxes" },
exists: () => false,
testboxId: "tbx_01kqap50t9fqggzw1akg5dtmmq",
});
expect(result.ok).toBe(false);
expect(result.claimPath).toBe(
"/state/testboxes/tbx_01kqap50t9fqggzw1akg5dtmmq/openclaw-runner.json",
);
expect(result.problems[0]).toContain("OpenClaw Testbox claim missing");
});
it("fails when an OpenClaw claim belongs to a different checkout", () => {
const result = evaluateOpenClawTestboxClaim({
cwd: "/repo/current",
env: { OPENCLAW_BLACKSMITH_TESTBOX_STATE_DIR: "/state/testboxes" },
exists: () => true,
now: () => new Date("2026-04-29T12:00:00.000Z"),
readFile: () => JSON.stringify({ repoRoot: "/repo/other" }),
testboxId: "tbx_01kqap50t9fqggzw1akg5dtmmq",
});
expect(result.ok).toBe(false);
expect(result.problems[0]).toContain("claim repo mismatch");
});
it("fails when an OpenClaw claim is stale after a crash or long pause", () => {
const result = evaluateOpenClawTestboxClaim({
cwd: "/repo/current",
env: {
OPENCLAW_BLACKSMITH_TESTBOX_STATE_DIR: "/state/testboxes",
OPENCLAW_TESTBOX_CLAIM_TTL_MINUTES: "90",
},
exists: () => true,
now: () => new Date("2026-04-29T14:00:00.000Z"),
readFile: () =>
JSON.stringify({
claimedAt: "2026-04-29T12:00:00.000Z",
repoRoot: "/repo/current",
}),
testboxId: "tbx_01kqap50t9fqggzw1akg5dtmmq",
});
expect(result.ok).toBe(false);
expect(result.problems[0]).toContain("claim is stale");
});
it("writes and accepts an OpenClaw Testbox claim for the current checkout", () => {
const writes = new Map<string, string>();
const claim = writeOpenClawTestboxClaim({
cwd: "/repo/current",
env: { OPENCLAW_BLACKSMITH_TESTBOX_STATE_DIR: "/state/testboxes" },
mkdir: () => undefined,
now: () => new Date("2026-04-29T12:00:00.000Z"),
testboxId: "tbx_01kqap50t9fqggzw1akg5dtmmq",
writeFile: (file, value) => writes.set(file, value),
});
expect(claim.payload).toEqual({
claimedAt: "2026-04-29T12:00:00.000Z",
repoRoot: "/repo/current",
runnerVersion: 1,
});
expect(
evaluateOpenClawTestboxClaim({
cwd: "/repo/current",
env: { OPENCLAW_BLACKSMITH_TESTBOX_STATE_DIR: "/state/testboxes" },
exists: (file) => writes.has(file),
now: () => new Date("2026-04-29T12:30:00.000Z"),
readFile: (file) => writes.get(file) ?? "",
testboxId: "tbx_01kqap50t9fqggzw1akg5dtmmq",
}).ok,
).toBe(true);
});
});