diff --git a/.agents/skills/blacksmith-testbox/SKILL.md b/.agents/skills/blacksmith-testbox/SKILL.md index 4d3a1b34228..7df209fd010 100644 --- a/.agents/skills/blacksmith-testbox/SKILL.md +++ b/.agents/skills/blacksmith-testbox/SKILL.md @@ -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 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 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 -- "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 +`. 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=`. + +Before spending a broad gate on a manually assembled command, you can also run: + + pnpm testbox:sanity -- --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 ` 3. Write code while the testbox boots in the background. 4. Run the remote command when needed: - `blacksmith testbox run --id "pnpm check:changed"` + `pnpm testbox:run --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 "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 "pnpm testbox:sanity"`. + `pnpm testbox:run --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. diff --git a/.agents/skills/openclaw-testing/SKILL.md b/.agents/skills/openclaw-testing/SKILL.md index 9dc12518ccf..1b2c0a720cb 100644 --- a/.agents/skills/openclaw-testing/SKILL.md +++ b/.agents/skills/openclaw-testing/SKILL.md @@ -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 "" ` 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 `, then prefer + `pnpm testbox:run --id -- ""` 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 diff --git a/package.json b/package.json index 64a5876780e..47248abb7db 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/blacksmith-testbox-runner.mjs b/scripts/blacksmith-testbox-runner.mjs new file mode 100644 index 00000000000..dddd3911e69 --- /dev/null +++ b/scripts/blacksmith-testbox-runner.mjs @@ -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 ` 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 \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(); +} diff --git a/scripts/blacksmith-testbox-state.mjs b/scripts/blacksmith-testbox-state.mjs new file mode 100644 index 00000000000..6f68b96037f --- /dev/null +++ b/scripts/blacksmith-testbox-state.mjs @@ -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 `.", + ); + 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 }; +} diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 9094ea7b070..a74674cc3e6 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -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", diff --git a/scripts/testbox-sync-sanity.mjs b/scripts/testbox-sync-sanity.mjs index 6499332d2bf..6942c385f58 100644 --- a/scripts/testbox-sync-sanity.mjs +++ b/scripts/testbox-sync-sanity.mjs @@ -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`, ); diff --git a/test/scripts/blacksmith-testbox-runner.test.ts b/test/scripts/blacksmith-testbox-runner.test.ts new file mode 100644 index 00000000000..b61727f96c0 --- /dev/null +++ b/test/scripts/blacksmith-testbox-runner.test.ts @@ -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()); + }); +}); diff --git a/test/scripts/blacksmith-testbox-state.test.ts b/test/scripts/blacksmith-testbox-state.test.ts new file mode 100644 index 00000000000..f536ab749e5 --- /dev/null +++ b/test/scripts/blacksmith-testbox-state.test.ts @@ -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(); + 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); + }); +});