mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
fix(testbox): guard stale OpenClaw Testbox reuse
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
122
scripts/blacksmith-testbox-runner.mjs
Normal file
122
scripts/blacksmith-testbox-runner.mjs
Normal 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();
|
||||
}
|
||||
194
scripts/blacksmith-testbox-state.mjs
Normal file
194
scripts/blacksmith-testbox-state.mjs
Normal 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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
94
test/scripts/blacksmith-testbox-runner.test.ts
Normal file
94
test/scripts/blacksmith-testbox-runner.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
119
test/scripts/blacksmith-testbox-state.test.ts
Normal file
119
test/scripts/blacksmith-testbox-state.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user