chore(crabbox): require Crabbox 0.67.0 (#160560)

* chore(crabbox): require Crabbox 0.67.0

* chore(crabbox): finalize the 0.67.0 minimum upgrade

* test(crabbox): refresh version fixtures across consumers
This commit is contained in:
Peter Steinberger 2026-09-28 11:29:35 -07:00 • committed by GitHub
parent 5312d5705e
commit eadee73a32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 146 additions and 196 deletions

View file

@ -278,13 +278,12 @@ is not generic compute offload. `.crabbox.yaml` defaults remote proof to
`blacksmith-testbox`. Its configured workflow hydrates provider and agent
credentials, so untrusted contributor or fork code must use secretless fork CI
or sanitized direct AWS Crabbox instead.
The wrapper uses the bundled Crabbox plugin's binary manager. Testbox requires
Crabbox 0.67.0 for task-owned SSH teardown, which prevents persistent SSH masters
from keeping idle Testboxes alive. Other providers and cloud-worker profiles
retain their 0.56.0 minimum, including supported offline configured binaries.
Missing or unsupported binaries use a verified managed 0.67.0 release. Testbox
selection upgrades an older candidate before lease work and refreshes its command
metadata. The original binary stays untouched. Provider readiness and broker authentication still determine
The wrapper uses the bundled Crabbox plugin's binary manager. All providers and
cloud-worker profiles require Crabbox 0.67.0 or newer. This includes task-owned
Testbox SSH teardown, which prevents persistent SSH masters from keeping idle
Testboxes alive. Missing or older binaries use a verified managed 0.67.0 release
before provider discovery or lease work. The original binary stays untouched.
Provider readiness and broker authentication still determine
which configured backend can run the proof.
The check workflow hydrates its pinned dispatch commit with a depth-1 checkout;
the changed gate later reconstructs the exact merge base and synced final tree.

View file

@ -69,7 +69,7 @@ The bundled `crabbox` provider provisions a disposable machine through the local
- `settings.class`: optional Crabbox machine class passed to `--class`. Omission leaves selection to Crabbox unless the placement supplies `machineClass`; OpenClaw does not invent a default or hardware size. Explicit `null`, empty or whitespace strings, and nonstring values are invalid. Edit classless profiles through **Settings → Advanced**.
- `settings.ttl` and `settings.idleTimeout` (required): positive Go duration strings passed to `--ttl` and `--idle-timeout` as provider-side failsafes.
- `settings.warmImage`: prepares a project's committed checkout and node runtime for capture before enrollment, then starts later workers for that project and profile from the image. Without a prepared Git project, capture remains at eligible worker teardown. Pair with `suspendAfter` so suspended sessions can wake warm. Enabled by default when a configured or placement class is known and `setupEnv` is empty or omitted. Without an effective class, omission stays cold. A nonempty `setupEnv` keeps the default cold because forwarded host environment could leave setup-derived credentials in a shared image. Explicit `true` opts in but requires a known effective class before provider commands; explicit `false` always stays cold. The resolved class and original cold/checkpoint choice are recorded before allocation and remain fixed through retries and restart. Images incur provider snapshot storage charges and retain machine-level caches, including pristine Git seeds, alongside whatever `setup` wrote outside scrubbed worker state. Scrubbing has a three-minute timeout. Checkpoint creation waits within Crabbox's native-capture budget plus command, source-lifecycle, and child-settlement allowances; it does not extend the configured lease TTL or idle timeout. An uncertain project capture blocks enrollment on its source but still permits lease cleanup. See [Warm images](/gateway/cloud-workers#warm-images) for refresh, retention, and Doctor migration and recovery.
- `settings.binary`: optional absolute Crabbox executable path. Without it, OpenClaw checks the sibling Crabbox checkout, then executable entries on `PATH`. The plugin requires Crabbox 0.56.0 or newer for every target, including Daytona fixed-ID preparation, replay, and confirmed cleanup. If the selected binary is missing, outdated, or cannot report a supported version, the plugin downloads the supported release into its own versioned directory under `$OPENCLAW_STATE_DIR/tools/crabbox` (by default `~/.openclaw/tools/crabbox`). It verifies the official release checksum and executable version before using the copy. Existing binaries and profile settings are preserved. Later commands reuse the managed installation without another download. Damaged managed installations are replaced automatically; the previous directory is retained beside the replacement with a `.recovery-<id>` suffix for inspection. `openclaw doctor --fix` installs the managed copy ahead of the first worker operation. An installation failure stops the operation before allocation and reports the cause.
- `settings.binary`: optional absolute Crabbox executable path. Without it, OpenClaw checks the sibling Crabbox checkout, then executable entries on `PATH`. The plugin requires Crabbox 0.67.0 or newer for every target, including Daytona fixed-ID preparation, replay, and confirmed cleanup. If the selected binary is missing, outdated, or cannot report a supported version, the plugin downloads the supported release into its own versioned directory under `$OPENCLAW_STATE_DIR/tools/crabbox` (by default `~/.openclaw/tools/crabbox`). It verifies the official release checksum and executable version before using the copy. Existing binaries and profile settings are preserved. Later commands reuse the managed installation without another download. Damaged managed installations are replaced automatically; the previous directory is retained beside the replacement with a `.recovery-<id>` suffix for inspection. `openclaw doctor --fix` installs the managed copy ahead of the first worker operation. An installation failure stops the operation before allocation and reports the cause.
- `readyWorkers`: non-negative integer target per eligible local project or repository and profile; defaults to `1`. Set `0` to disable this profile's reserves while keeping warm-image reuse.
- `cloudWorkers.preparedPool.maxTotal`: non-negative integer Gateway-wide reserve cap; defaults to `4`. Preparing workers and unconfirmed cleanup count toward both limits. Set `0` to drain unused reserves and stop refill. Reserves incur running-machine charges and expire from successful project demand using the provider's existing idle policy. See [Ready workers](/gateway/cloud-workers/warm-images#ready-workers).

View file

@ -32,7 +32,7 @@ immediately before delegation. Source-only edits can reuse the box while HEAD
and preparation inputs remain unchanged; every run syncs the checkout.
Older or missing receipts require stopping the owned lease and allocating a
fresh one through the wrapper. `OPENCLAW_TESTBOX_ALLOW_STALE` cannot bypass
these checks. Testbox requires Crabbox 0.67.0; other providers retain 0.56.0.
these checks. All providers require Crabbox 0.67.0 or newer.
The Testbox workflow registers a separate disposable checkout for native sync.
The hydrated execution workspace stays at its original absolute path, so native

View file

@ -84,7 +84,7 @@ describe("Crabbox plugin generation lifecycle", () => {
beforeEach(() => {
vi.spyOn(managedBinary, "ensureManagedCrabboxBinary").mockImplementation(async (params) => ({
binary: params?.binary ?? "crabbox",
version: "0.55.0",
version: "999.0.0",
}));
});
afterEach(async () => {
@ -355,7 +355,7 @@ describe("Crabbox plugin generation lifecycle", () => {
started.resolve(params.signal);
await finish.promise;
params.signal.throwIfAborted();
return { binary: params.binary ?? "crabbox", version: "0.55.0" };
return { binary: params.binary ?? "crabbox", version: "999.0.0" };
});
}
const generation = registerCrabboxGeneration();

View file

@ -10,11 +10,14 @@ import { useAutoCleanupTempDirTracker } from "openclaw/plugin-sdk/test-env";
import * as tar from "tar";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ensureManagedCrabboxBinary } from "../cli-runtime-api.js";
import { probeCrabboxVersion, resolveManagedCrabboxBinaryPath } from "./crabbox-managed-binary.js";
import {
CRABBOX_MIN_VERSION,
probeCrabboxVersion,
resolveManagedCrabboxBinaryPath,
} from "./crabbox-managed-binary.js";
import type { CrabboxCommandRunner } from "./crabbox-worker-command.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const managedVersion = "0.67.0";
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
@ -29,7 +32,7 @@ const runCommand: CrabboxCommandRunner = async ([binary]) => ({
termination: "exit",
});
async function fixture(version = "0.55.0") {
async function fixture(version = "0.66.0") {
const root = tempDirs.make("crabbox-managed-");
const env = { ...process.env, OPENCLAW_STATE_DIR: path.join(root, "state") };
const candidate = path.join(root, "operator-crabbox");
@ -38,8 +41,8 @@ async function fixture(version = "0.55.0") {
const platform = process.platform === "win32" ? "windows" : process.platform;
const arch = process.arch === "x64" ? "amd64" : process.arch;
const extension = platform === "windows" ? "zip" : "tar.gz";
const asset = `crabbox_${managedVersion}_${platform}_${arch}.${extension}`;
const files = { [path.basename(binary)]: managedVersion, "companion-helper": "keep me" };
const asset = `crabbox_${CRABBOX_MIN_VERSION}_${platform}_${arch}.${extension}`;
const files = { [path.basename(binary)]: CRABBOX_MIN_VERSION, "companion-helper": "keep me" };
let archive: Buffer;
if (platform === "windows") {
const zip = new JSZip();
@ -73,7 +76,7 @@ async function fixture(version = "0.55.0") {
checksums,
fetch,
options: { binary: candidate, env, runCommand },
installed: { binary, version: managedVersion },
installed: { binary, version: CRABBOX_MIN_VERSION },
};
}
@ -83,7 +86,7 @@ describe("managed Crabbox", () => {
const binary = path.join(root, "crabbox");
const env = { PATH: root, OPENCLAW_STATE_DIR: path.join(root, "state") };
const command = vi.spyOn(processRuntime, "runCommandWithTimeout").mockResolvedValue({
stdout: "crabbox 0.67.0",
stdout: `crabbox ${CRABBOX_MIN_VERSION}`,
stderr: "",
code: 0,
signal: null,
@ -93,7 +96,7 @@ describe("managed Crabbox", () => {
await expect(ensureManagedCrabboxBinary({ binary, env, cwd: root })).resolves.toEqual({
binary,
version: "0.67.0",
version: CRABBOX_MIN_VERSION,
});
expect(command).toHaveBeenCalledExactlyOnceWith(
[binary, "--version"],
@ -101,22 +104,19 @@ describe("managed Crabbox", () => {
);
});
it.each(["0.56.0", "0.66.0", "1.0.0"])(
"keeps supported non-Testbox %s offline",
async (version) => {
const test = await fixture(version);
await expect(ensureManagedCrabboxBinary(test.options)).resolves.toEqual({
binary: test.candidate,
version,
});
expect(test.fetch).not.toHaveBeenCalled();
await expect(fs.access(test.env.OPENCLAW_STATE_DIR)).rejects.toMatchObject({
code: "ENOENT",
});
},
);
it.each([CRABBOX_MIN_VERSION, "999.0.0"])("keeps supported %s offline", async (version) => {
const test = await fixture(version);
await expect(ensureManagedCrabboxBinary(test.options)).resolves.toEqual({
binary: test.candidate,
version,
});
expect(test.fetch).not.toHaveBeenCalled();
await expect(fs.access(test.env.OPENCLAW_STATE_DIR)).rejects.toMatchObject({
code: "ENOENT",
});
});
it("reuses the prior managed cache offline but never for Testbox", async () => {
it("upgrades an older managed cache without changing its files", async () => {
const test = await fixture();
const prior = path.join(
test.env.OPENCLAW_STATE_DIR,
@ -126,20 +126,16 @@ describe("managed Crabbox", () => {
);
await fs.mkdir(path.dirname(prior), { recursive: true });
await fs.writeFile(prior, "0.56.0");
await expect(ensureManagedCrabboxBinary(test.options)).resolves.toEqual({
binary: prior,
version: "0.56.0",
});
expect(test.fetch).not.toHaveBeenCalled();
await expect(
ensureManagedCrabboxBinary({ ...test.options, minimumVersion: "0.67.0" }),
).resolves.toEqual(test.installed);
expect(test.fetch).toHaveBeenCalled();
await expect(ensureManagedCrabboxBinary(test.options)).resolves.toEqual(test.installed);
expect(test.fetch).toHaveBeenCalledTimes(2);
expect(await fs.readFile(prior, "utf8")).toBe("0.56.0");
test.fetch.mockRejectedValue(new Error("offline"));
await expect(ensureManagedCrabboxBinary(test.options)).resolves.toEqual(test.installed);
expect(test.fetch).toHaveBeenCalledTimes(2);
});
it("keeps a supported configured binary through startup contention", async () => {
const test = await fixture("0.56.0");
const test = await fixture(CRABBOX_MIN_VERSION);
const started = createDeferred<void>();
const delayedRunner: CrabboxCommandRunner = async (argv, options) => {
const result = await runCommand(argv, options);
@ -177,14 +173,16 @@ describe("managed Crabbox", () => {
await started.promise;
await vi.advanceTimersByTimeAsync(7_000);
vi.useRealTimers();
await expect(result).resolves.toEqual({ value: { binary: test.candidate, version: "0.56.0" } });
await expect(result).resolves.toEqual({
value: { binary: test.candidate, version: CRABBOX_MIN_VERSION },
});
expect(test.fetch).not.toHaveBeenCalled();
await expect(fs.access(test.env.OPENCLAW_STATE_DIR)).rejects.toMatchObject({ code: "ENOENT" });
});
it("upgrades a candidate without native SSH teardown, preserves its distribution, and reuses it offline", async () => {
it("upgrades an old candidate, preserves its distribution, and reuses it offline", async () => {
const test = await fixture("0.66.0");
const params = { ...test.options, minimumVersion: "0.67.0" as const };
const params = test.options;
await expect(ensureManagedCrabboxBinary(params)).resolves.toEqual(test.installed);
expect(await fs.readFile(test.candidate, "utf8")).toBe("0.66.0");
expect(
@ -225,7 +223,7 @@ describe("managed Crabbox", () => {
});
await expect(ensureManagedCrabboxBinary(params)).rejects.toThrow("checksum mismatch");
expect(await fs.readdir(path.dirname(path.dirname(test.binary)))).toEqual([]);
expect(await fs.readFile(test.candidate, "utf8")).toBe("0.55.0");
expect(await fs.readFile(test.candidate, "utf8")).toBe("0.66.0");
await expect(ensureManagedCrabboxBinary(params)).resolves.toEqual(test.installed);
});
@ -292,7 +290,7 @@ describe("managed Crabbox", () => {
env: test.env,
runCommand: staleRunner,
}),
).rejects.toThrow("does not satisfy 0.67.0");
).rejects.toThrow(`does not satisfy ${CRABBOX_MIN_VERSION}`);
expect(await fs.readdir(path.dirname(path.dirname(test.binary)))).toEqual([]);
});
@ -365,14 +363,14 @@ describe("managed Crabbox", () => {
vi.spyOn(fs, "rename").mockImplementation(async (source, destination) => {
if (destination === path.dirname(test.binary) && typeof source === "string") {
await fs.cp(source, destination, { recursive: true });
await fs.writeFile(test.binary, "0.67.0");
await fs.writeFile(test.binary, CRABBOX_MIN_VERSION);
throw Object.assign(new Error("destination exists"), { code: "EEXIST" });
}
await rename(source, destination);
});
await expect(ensureManagedCrabboxBinary(test.options)).resolves.toEqual({
binary: test.binary,
version: "0.67.0",
version: CRABBOX_MIN_VERSION,
});
expect(
await fs.readFile(path.join(path.dirname(test.binary), "companion-helper"), "utf8"),
@ -390,8 +388,8 @@ describe("managed Crabbox", () => {
}
await fs.writeFile(path.join(destination, "operator-note"), "preserve this");
await expect(ensureManagedCrabboxBinary(test.options)).resolves.toEqual(test.installed);
expect(await fs.readFile(test.binary, "utf8")).toBe(managedVersion);
expect(await fs.readFile(test.candidate, "utf8")).toBe("0.55.0");
expect(await fs.readFile(test.binary, "utf8")).toBe(CRABBOX_MIN_VERSION);
expect(await fs.readFile(test.candidate, "utf8")).toBe("0.66.0");
const parent = path.dirname(destination);
const entries = await fs.readdir(parent);
const backups = entries.filter((name) =>
@ -449,7 +447,7 @@ describe("managed Crabbox", () => {
});
const params = test.options;
await expect(ensureManagedCrabboxBinary(params)).rejects.toThrow("cleanup blocked");
expect(await fs.readFile(test.binary, "utf8")).toBe(managedVersion);
expect(await fs.readFile(test.binary, "utf8")).toBe(CRABBOX_MIN_VERSION);
const parent = path.dirname(destination);
const backup = (await fs.readdir(parent)).find((name) =>
name.startsWith(`${path.basename(destination)}.recovery-`),
@ -589,10 +587,10 @@ describe("managed Crabbox", () => {
describe("Crabbox version admission", () => {
it.each([
["0.55.0", "outdated"],
["0.56.0", "supported"],
["0.56.0-rc.1", "outdated"],
["0.66.0", "supported"],
["0.67.0", "supported"],
["0.56.0", "outdated"],
["0.67.0-rc.1", "outdated"],
["0.66.0", "outdated"],
[CRABBOX_MIN_VERSION, "supported"],
["0.67.0+build.1", "supported"],
["0.68.0-dev", "supported"],
["0.9007199254740993.0", "indeterminate"],
@ -603,11 +601,4 @@ describe("Crabbox version admission", () => {
status,
});
});
it.each(["0.56.0", "0.66.0", "0.67.0-rc.1"])("rejects %s for Testbox", async (version) => {
const test = await fixture(version);
await expect(
probeCrabboxVersion(test.candidate, runCommand, undefined, "0.67.0"),
).resolves.toMatchObject({ status: "outdated" });
});
});

View file

@ -6,11 +6,8 @@ import { runCommandWithTimeout, type SpawnResult } from "openclaw/plugin-sdk/pro
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import type { CrabboxCommandRunner } from "./crabbox-worker-command.js";
export const CRABBOX_MIN_VERSION = "0.56.0";
// Managed installs include the native Testbox SSH cleanup fix. Existing offline
// cloud workers retain their supported floor; provider callers can require more.
const MANAGED_VERSION = "0.67.0";
const RELEASE_URL = `https://github.com/openclaw/crabbox/releases/download/v${MANAGED_VERSION}`;
export const CRABBOX_MIN_VERSION = "0.67.0";
const RELEASE_URL = `https://github.com/openclaw/crabbox/releases/download/v${CRABBOX_MIN_VERSION}`;
const MAX_ARCHIVE_BYTES = 128 * 1024 * 1024;
// Gateway startup contention can delay an otherwise healthy executable probe.
const VERSION_TIMEOUT_MS = 30_000;
@ -28,7 +25,6 @@ export async function probeCrabboxVersion(
binary: string,
runCommand: CrabboxCommandRunner = runCommandWithTimeout,
signal?: AbortSignal,
minimumVersion = CRABBOX_MIN_VERSION,
): Promise<CrabboxVersionProbe> {
signal?.throwIfAborted();
let result: SpawnResult;
@ -65,7 +61,7 @@ export async function probeCrabboxVersion(
if (current.some((part) => !Number.isSafeInteger(part))) {
return { status: "indeterminate", reason: "version output was not recognized" };
}
const minimum = minimumVersion.split(".").map(Number);
const minimum = CRABBOX_MIN_VERSION.split(".").map(Number);
const difference = current.findIndex((part, index) => part !== minimum[index]);
const supported = difference === -1 ? !match[5] : current[difference]! > minimum[difference]!;
return { status: supported ? "supported" : "outdated", version: match[1]! };
@ -81,27 +77,23 @@ function releaseTarget() {
return {
directory: `${platform}-${arch}`,
executable: platform === "windows" ? "crabbox.exe" : "crabbox",
asset: `crabbox_${MANAGED_VERSION}_${platform}_${arch}.${extension}`,
asset: `crabbox_${CRABBOX_MIN_VERSION}_${platform}_${arch}.${extension}`,
tar: extension === "tar.gz",
};
}
function managedBinaryPath(env: NodeJS.ProcessEnv, version: string): string {
export function resolveManagedCrabboxBinaryPath(env: NodeJS.ProcessEnv = process.env): string {
const target = releaseTarget();
return path.join(
resolveStateDir(env),
"tools",
"crabbox",
version,
CRABBOX_MIN_VERSION,
target.directory,
target.executable,
);
}
export function resolveManagedCrabboxBinaryPath(env: NodeJS.ProcessEnv = process.env): string {
return managedBinaryPath(env, MANAGED_VERSION);
}
async function downloadReleaseFile(
destination: Awaited<ReturnType<typeof root>>,
name: string,
@ -166,19 +158,15 @@ async function probeInstallation(
binary: string,
runCommand: CrabboxCommandRunner,
signal: AbortSignal | undefined,
minimumVersion = MANAGED_VERSION,
): Promise<CrabboxBinary | undefined> {
const stat = await fs.lstat(binary).catch(() => undefined);
if (!stat?.isFile()) {
return undefined;
}
const result = await probeCrabboxVersion(binary, runCommand, signal, minimumVersion);
const result = await probeCrabboxVersion(binary, runCommand, signal);
return result.status === "supported" ? { binary, version: result.version } : undefined;
}
// Detection and execution share this read-only selection. A release refresh
// must not strand supported offline workers in the previous managed cache;
// Testbox's higher requirement bypasses this default-floor lookup.
export async function findManagedCrabboxBinary(
params: {
env?: NodeJS.ProcessEnv;
@ -189,14 +177,9 @@ export async function findManagedCrabboxBinary(
const runCommand =
params.runCommand ??
((argv, options) => runCommandWithTimeout(argv, { ...options, baseEnv: params.env }));
for (const cachedVersion of [MANAGED_VERSION, CRABBOX_MIN_VERSION]) {
const binary = managedBinaryPath(params.env ?? process.env, cachedVersion);
if (await inspectInstallationDirectory(path.dirname(binary))) {
const cached = await probeInstallation(binary, runCommand, params.signal, cachedVersion);
if (cached) {
return cached;
}
}
const binary = resolveManagedCrabboxBinaryPath(params.env);
if (await inspectInstallationDirectory(path.dirname(binary))) {
return probeInstallation(binary, runCommand, params.signal);
}
return undefined;
}
@ -349,7 +332,7 @@ async function installManagedBinary(
const stagedBinary = path.join(payload, target.executable);
const staged = await probeInstallation(stagedBinary, runCommand, signal);
if (!staged) {
throw new Error(`Downloaded Crabbox executable does not satisfy ${MANAGED_VERSION}`);
throw new Error(`Downloaded Crabbox executable does not satisfy ${CRABBOX_MIN_VERSION}`);
}
return await publishInstallation({
binary,
@ -377,7 +360,6 @@ export async function ensureManagedCrabboxBinary(
runCommand?: CrabboxCommandRunner;
env?: NodeJS.ProcessEnv;
signal?: AbortSignal;
minimumVersion?: typeof CRABBOX_MIN_VERSION | typeof MANAGED_VERSION;
} = {},
): Promise<CrabboxBinary> {
const { signal } = params;
@ -387,24 +369,16 @@ export async function ensureManagedCrabboxBinary(
runCommandWithTimeout(argv, { ...options, baseEnv: params.env, cwd: params.cwd }));
const candidate = params.binary ?? "crabbox";
const binary = resolveManagedCrabboxBinaryPath(params.env);
const managedCandidate = path.resolve(params.cwd ?? ".", candidate) === binary;
if (managedCandidate) {
if (path.resolve(params.cwd ?? ".", candidate) === binary) {
await inspectInstallationDirectory(path.dirname(binary));
}
const preferred = await probeCrabboxVersion(
candidate,
runCommand,
signal,
managedCandidate ? MANAGED_VERSION : params.minimumVersion,
);
const preferred = await probeCrabboxVersion(candidate, runCommand, signal);
if (preferred.status === "supported") {
return { binary: candidate, version: preferred.version };
}
if ((params.minimumVersion ?? CRABBOX_MIN_VERSION) === CRABBOX_MIN_VERSION) {
const cached = await findManagedCrabboxBinary({ env: params.env, runCommand, signal });
if (cached) {
return cached;
}
const cached = await findManagedCrabboxBinary({ env: params.env, runCommand, signal });
if (cached) {
return cached;
}
const { toErrorObject } = await import("openclaw/plugin-sdk/error-runtime");
signal?.throwIfAborted();

View file

@ -44,7 +44,7 @@ import {
vi.mock("./crabbox-managed-binary.js", () => ({
ensureManagedCrabboxBinary: vi.fn(async ({ binary }: { binary: string }) => ({
binary,
version: "0.55.0",
version: "999.0.0",
})),
}));
@ -92,7 +92,7 @@ beforeEach(() => {
.mockReset()
.mockImplementation(async (params) => ({
binary: params?.binary ?? "crabbox",
version: "0.55.0",
version: "999.0.0",
}));
// Provider instances share durable state within a replay test, never across test cases.
vi.stubEnv("OPENCLAW_STATE_DIR", tempDirs.make("openclaw-crabbox-provider-"));
@ -255,7 +255,7 @@ describe("Crabbox worker provider", () => {
const managedBinary = path.resolve(path.sep, "managed", "crabbox");
vi.mocked(ensureManagedCrabboxBinary).mockResolvedValue({
binary: managedBinary,
version: "0.55.0",
version: "999.0.0",
});
const runCommand = vi.fn<CrabboxCommandRunner>(async (argv) => {
if (argv[1] === "providers") {
@ -334,7 +334,7 @@ describe("Crabbox worker provider", () => {
controller.abort();
acquisition.resolve({
binary: path.resolve(path.sep, "managed", "crabbox"),
version: "0.55.0",
version: "999.0.0",
});
await rejected;

View file

@ -46,7 +46,7 @@ describe("Crabbox idle image maintenance", () => {
if (params?.binary === "/opt/b/crabbox") {
throw new Error("fixture binary acquisition unavailable");
}
return { binary: params?.binary ?? "crabbox", version: "0.55.0" };
return { binary: params?.binary ?? "crabbox", version: "999.0.0" };
});
const store = openWarmImageStore();
store.register("expired", expiredImage("chk_expired"));

View file

@ -71,7 +71,7 @@ export function createWarmProvider(
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
vi.spyOn(managedBinary, "ensureManagedCrabboxBinary").mockImplementation(async (params) => ({
binary: params?.binary ?? "crabbox",
version: "0.55.0",
version: "999.0.0",
}));
const calls: CommandCall[] = [];
const warn = vi.fn();

View file

@ -68,7 +68,7 @@ describe("Crabbox worker doctor", () => {
it("accepts a supported configured executable without downloading", async () => {
const probe = vi
.spyOn(managedBinary, "probeCrabboxVersion")
.mockResolvedValue({ status: "supported", version: "0.56.0" });
.mockResolvedValue({ status: "supported", version: managedBinary.CRABBOX_MIN_VERSION });
const install = vi.spyOn(managedBinary, "ensureManagedCrabboxBinary");
await expect(captureCrabboxDoctorCheck().detect(context())).resolves.toEqual([]);
expect(probe).toHaveBeenCalledOnce();
@ -87,7 +87,7 @@ describe("Crabbox worker doctor", () => {
expect.objectContaining({
severity: "warning",
target: "worker",
requirement: "Crabbox 0.56.0 or newer",
requirement: `Crabbox ${managedBinary.CRABBOX_MIN_VERSION} or newer`,
fixHint: expect.stringContaining("openclaw doctor --fix"),
}),
]);
@ -95,8 +95,8 @@ describe("Crabbox worker doctor", () => {
},
);
it.each(["0.56.0", "0.67.0"])(
"detects managed-only %s offline without repair",
it.each(["0.56.0", managedBinary.CRABBOX_MIN_VERSION])(
"detects whether managed-only %s needs an upgrade without downloading",
async (version) => {
const env = { OPENCLAW_STATE_DIR: tempDirs.make("crabbox-doctor-managed-"), PATH: "" };
const target = path.basename(
@ -125,10 +125,21 @@ describe("Crabbox worker doctor", () => {
ctx.cfg.cloudWorkers!.profiles!.worker!.settings = { binary: "/nonexistent/crabbox" };
const check = captureCrabboxDoctorCheck();
const findings = await check.detect(ctx);
expect(findings).toEqual([]);
await expect(check.repair!(ctx, findings)).resolves.toMatchObject({ status: "skipped" });
await expect(check.detect(ctx)).resolves.toEqual([]);
expect(command).toHaveBeenCalledWith([binary, "--version"], expect.anything());
if (version === managedBinary.CRABBOX_MIN_VERSION) {
expect(findings).toEqual([]);
await expect(check.repair!(ctx, findings)).resolves.toMatchObject({ status: "skipped" });
await expect(check.detect(ctx)).resolves.toEqual([]);
expect(command).toHaveBeenCalledWith([binary, "--version"], expect.anything());
} else {
expect(findings).toEqual([
expect.objectContaining({
severity: "warning",
requirement: `Crabbox ${managedBinary.CRABBOX_MIN_VERSION} or newer`,
fixHint: expect.stringContaining("openclaw doctor --fix"),
}),
]);
expect(command).not.toHaveBeenCalled();
}
expect(install).not.toHaveBeenCalled();
},
);
@ -152,7 +163,7 @@ describe("Crabbox worker doctor", () => {
.spyOn(managedBinary, "ensureManagedCrabboxBinary")
.mockImplementation(async ({ binary } = {}) => ({
binary: binary ?? "crabbox",
version: "0.56.0",
version: managedBinary.CRABBOX_MIN_VERSION,
}));
const check = captureCrabboxDoctorCheck();
const findings = [{ checkId: CRABBOX_CLOUD_WORKER_PROFILE_CHECK_ID }] as never;

View file

@ -27,8 +27,8 @@ describe("Mantis Crabbox binary admission", () => {
await fs.writeFile(
executable,
process.platform === "win32"
? '@echo off\r\n> "%CRABBOX_PROBE_CWD_FILE%" echo %CD%\r\necho crabbox 0.56.0\r\n'
: '#!/bin/sh\npwd -P > "$CRABBOX_PROBE_CWD_FILE"\nprintf "crabbox 0.56.0\\n"\n',
? '@echo off\r\n> "%CRABBOX_PROBE_CWD_FILE%" echo %CD%\r\necho crabbox 999.0.0\r\n'
: '#!/bin/sh\npwd -P > "$CRABBOX_PROBE_CWD_FILE"\nprintf "crabbox 999.0.0\\n"\n',
{ mode: 0o755 },
);
const download = vi

View file

@ -14,7 +14,7 @@ vi.mock("@openclaw/crabbox-provider/cli-runtime-api.js", async (importOriginal)
...actual,
ensureManagedCrabboxBinary: vi.fn(async ({ binary }: { binary: string }) => ({
binary,
version: "0.55.0",
version: "999.0.0",
})),
};
});
@ -49,7 +49,7 @@ describe("mantis desktop browser smoke runtime", () => {
it("uses the managed binary to lease a desktop, run a browser, copy artifacts, and stop", async () => {
vi.mocked(ensureManagedCrabboxBinary).mockResolvedValueOnce({
binary: "/tmp/crabbox",
version: "0.55.0",
version: "999.0.0",
});
await fs.mkdir(path.join(repoRoot, "qa-artifacts"), { recursive: true });
await fs.writeFile(path.join(repoRoot, "qa-artifacts", "timeline.html"), "<h1>Mantis</h1>");

View file

@ -16,7 +16,7 @@ vi.mock("@openclaw/crabbox-provider/cli-runtime-api.js", async (importOriginal)
...actual,
ensureManagedCrabboxBinary: vi.fn(async ({ binary }: { binary: string }) => ({
binary,
version: "0.55.0",
version: "999.0.0",
})),
};
});

View file

@ -16,7 +16,7 @@ vi.mock("@openclaw/crabbox-provider/cli-runtime-api.js", async (importOriginal)
...actual,
ensureManagedCrabboxBinary: vi.fn(async ({ binary }: { binary: string }) => ({
binary,
version: "0.55.0",
version: "999.0.0",
})),
};
});

View file

@ -12,7 +12,7 @@ vi.mock("@openclaw/crabbox-provider/cli-runtime-api.js", async (importOriginal)
...actual,
ensureManagedCrabboxBinary: vi.fn(async ({ binary }: { binary: string }) => ({
binary,
version: "0.55.0",
version: "999.0.0",
})),
};
});

View file

@ -33,7 +33,6 @@ import { StringDecoder } from "node:string_decoder";
import { setImmediate as yieldToSignals } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { gte as semverGte } from "semver";
import {
ensureManagedCrabboxBinary,
findCrabboxBinary,
@ -123,7 +122,7 @@ try {
console.error(`[crabbox] ${error instanceof Error ? error.message : String(error)}`);
process.exit(2);
}
let { binary, version } = cli;
const { binary, version } = cli;
const workloadCommand = isWorkloadRoutedCommand(args);
const workloadOption = workloadCommand ? extractWrapperValueOption(args, "--workload") : undefined;
const userArgStart = commandUserArgStart(args);
@ -3708,22 +3707,18 @@ async function applyRunTransforms(
}
const helpCommand = workloadCommand ? args.slice(0, userArgStart) : ["run"];
function cliMetadata() {
const help = probeCrabboxHelp(binary, [...helpCommand, "--help"]);
const providers = parseProvidersFromHelp(help.text);
commandValueOptionsFromHelp = parseCommandValueOptionsFromHelp(help.text);
const displayBinary = binary === "crabbox" ? "crabbox" : relative(repoRoot, binary);
const help = probeCrabboxHelp(binary, [...helpCommand, "--help"]);
const providers = parseProvidersFromHelp(help.text);
commandValueOptionsFromHelp = parseCommandValueOptionsFromHelp(help.text);
const displayBinary = binary === "crabbox" ? "crabbox" : relative(repoRoot, binary);
if (help.status !== 0 || commandValueOptionsFromHelp.size === 0) {
console.error(
`[crabbox] bin=${displayBinary} version=${version} providers=${providers.join(",") || "unknown"}`,
);
console.error("[crabbox] selected binary failed --help sanity checks");
process.exit(2);
}
return { help, providers, displayBinary };
if (help.status !== 0 || commandValueOptionsFromHelp.size === 0) {
console.error(
`[crabbox] bin=${displayBinary} version=${version} providers=${providers.join(",") || "unknown"}`,
);
console.error("[crabbox] selected binary failed --help sanity checks");
process.exit(2);
}
let { help, providers, displayBinary } = cliMetadata();
// Classify help before removing the wrapper separator or preparing execution.
// Only the leaf's option prefix counts; payloads and flag values stay gated.
@ -3754,27 +3749,7 @@ if (args[userArgStart] === "--") {
args.splice(userArgStart, 1);
}
let providerSelection = selectedProvider(args, providers);
if (
!providerSelection.error &&
canonicalProviderName(providerSelection.provider) === "blacksmith-testbox" &&
!semverGte(version, "0.67.0")
) {
// Only Testbox needs the native SSH lifetime repair. Preserve supported
// offline binaries for other providers, and parse against the executable used.
try {
({ binary, version } = await ensureManagedCrabboxBinary({
binary,
minimumVersion: "0.67.0",
}));
resolvedCrabboxConfigCache = undefined;
({ help, providers, displayBinary } = cliMetadata());
providerSelection = selectedProvider(args, providers);
} catch (error) {
console.error(`[crabbox] ${error instanceof Error ? error.message : String(error)}`);
process.exit(2);
}
}
const providerSelection = selectedProvider(args, providers);
if (providerSelection.error) {
console.error(`[crabbox] ${providerSelection.error}`);
if (providerSelection.readiness) {

View file

@ -93,7 +93,7 @@ describe("Crabbox runtime preflight cleanup", () => {
.mockImplementation(async (argv) => {
if (argv[1] === "--version") {
expect(argv.slice(1)).toEqual(["--version"]);
return commandResult({ stdout: "0.56.0" });
return commandResult({ stdout: "999.0.0" });
}
if (argv[1] === "providers") {
expect(argv.slice(1)).toEqual(["providers", "--json"]);
@ -247,7 +247,7 @@ describe("Crabbox runtime preflight cleanup", () => {
vi.spyOn(processRuntime, "runCommandWithTimeout").mockImplementation(async (argv) => {
if (argv[1] === "--version") {
expect(argv.slice(1)).toEqual(["--version"]);
return commandResult({ stdout: "0.56.0" });
return commandResult({ stdout: "999.0.0" });
}
calls.push(argv);
if (argv[1] === "providers") {
@ -388,7 +388,7 @@ describe("Crabbox runtime preflight cleanup", () => {
.mockImplementation(async (argv) => {
if (argv[1] === "--version") {
expect(argv.slice(1)).toEqual(["--version"]);
return commandResult({ stdout: "0.56.0" });
return commandResult({ stdout: "999.0.0" });
}
expect(argv.slice(1)).toEqual(["providers", "--json"]);
return commandResult({ stdout: "[]" });

View file

@ -87,7 +87,7 @@ describe("Crabbox allocation through Gateway ownership", () => {
.mockImplementation(async (argv) => {
const value = (flag: string) => argv[argv.indexOf(flag) + 1]!;
if (argv[1] === "--version") {
return result("crabbox 0.56.0\n");
return result("crabbox 999.0.0\n");
}
if (argv[1] === "config") {
return result(JSON.stringify({ aws: { instanceProfile: "" } }));

View file

@ -58,7 +58,7 @@ describe("Crabbox service replacement", () => {
return {
stdout:
argv[1] === "--version"
? "0.56.0"
? "999.0.0"
: JSON.stringify({
id: "cbx_replacement",
providerMetadata: { instanceProfileAttached: false },

View file

@ -1598,7 +1598,7 @@ describe("scripts/crabbox-wrapper", () => {
env: {
GITHUB_PATH: githubPath,
OPENCLAW_STATE_DIR: path.join(directory, "state"),
OPENCLAW_FAKE_CRABBOX_VERSION: "crabbox 0.56.0",
OPENCLAW_FAKE_CRABBOX_VERSION: "crabbox 999.0.0",
OPENCLAW_FAKE_CRABBOX_INVOCATION_LOG: invocationLog,
},
}),
@ -1609,7 +1609,7 @@ describe("scripts/crabbox-wrapper", () => {
makeFakeCrabbox(defaultProviderHelp),
process.platform === "win32" ? "crabbox.cmd" : "crabbox",
);
expect(JSON.parse(result.stdout)).toEqual({ binary, version: "0.56.0" });
expect(JSON.parse(result.stdout)).toEqual({ binary, version: "999.0.0" });
expect(readFileSync(githubPath, "utf8")).toBe(`${path.dirname(binary)}\n`);
expect(readInvocations(invocationLog)).toEqual([["--version"]]);
expect(existsSync(path.join(directory, "state"))).toBe(false);
@ -1628,20 +1628,24 @@ describe("scripts/crabbox-wrapper", () => {
expect(result.stderr).toContain("selected=aws");
});
it.skipIf(process.platform === "win32")(
"upgrades only Testbox and refreshes native metadata",
() => {
const root = invocationLogTempDirs.make("openclaw-testbox-version-");
it.skipIf(process.platform === "win32").each(["aws", "blacksmith-testbox"])(
"upgrades an old binary before metadata and lease commands for %s",
(provider) => {
const root = invocationLogTempDirs.make("openclaw-crabbox-version-");
const stateDir = path.join(root, "state");
const platform = process.platform;
const arch = process.arch === "x64" ? "amd64" : process.arch;
const managed = path.join(stateDir, "tools/crabbox/0.67.0", `${platform}-${arch}`, "crabbox");
mkdirSync(path.dirname(managed), { recursive: true });
// Use the logging Node fake so both metadata probes are observable.
// Keep candidate and managed commands distinguishable at the executable boundary.
const fake = path.join(makeFakeCrabbox(defaultProviderHelp), "crabbox-node");
const candidate = path.join(root, "bin", "crabbox");
const candidateLog = makeInvocationLog();
mkdirSync(path.dirname(candidate));
writeShellCommand(candidate, `exec node ${shellQuote(fake)} "$@"`);
writeShellCommand(
candidate,
`OPENCLAW_FAKE_CRABBOX_INVOCATION_LOG=${shellQuote(candidateLog)} exec node ${shellQuote(fake)} "$@"`,
);
writeShellCommand(
managed,
`unset OPENCLAW_FAKE_CRABBOX_VERSION\nexec node ${shellQuote(fake)} "$@"`,
@ -1655,19 +1659,15 @@ describe("scripts/crabbox-wrapper", () => {
OPENCLAW_FAKE_CRABBOX_INVOCATION_LOG: log,
},
};
const offline = runDefaultWrapper(["run", "--provider", "aws", "--", "true"], options);
expect(offline.status, offline.stderr).toBe(0);
expect(offline.stderr).toContain("version=0.56.0 provider=aws");
writeFileSync(log, "");
const testbox = runDefaultWrapper(
["run", "--provider", "blacksmith-testbox", "--", "true"],
options,
);
expect(testbox.status, testbox.stderr).toBe(0);
expect(testbox.stderr).toContain("version=0.67.0 provider=blacksmith-testbox");
const result = runDefaultWrapper(["run", "--provider", provider, "--", "true"], options);
expect(result.status, result.stderr).toBe(0);
expect(result.stderr).toContain(`version=0.67.0 provider=${provider}`);
expect(readInvocations(candidateLog)).toEqual([["--version"]]);
const calls = readInvocations(log);
expect(calls.filter((args) => args[0] === "run" && args[1] === "--help")).toHaveLength(2);
expect(calls.filter((args) => args[0] === "config" && args[1] === "show")).toHaveLength(2);
expect(calls[0]).toEqual(["--version"]);
expect(calls.filter((args) => args[0] === "run" && args[1] === "--help")).toHaveLength(1);
expect(calls.filter((args) => args[0] === "config" && args[1] === "show")).toHaveLength(1);
expect(calls.filter((args) => args[0] === "run" && args[1] !== "--help")).toHaveLength(1);
},
);
@ -1851,7 +1851,7 @@ describe("scripts/crabbox-wrapper", () => {
{
env: {
OPENCLAW_FAKE_CRABBOX_INVOCATION_LOG: invocationLog,
OPENCLAW_FAKE_CRABBOX_VERSION: "crabbox 0.56.0",
OPENCLAW_FAKE_CRABBOX_VERSION: "crabbox 999.0.0",
OPENCLAW_FAKE_CRABBOX_UNREADY_PROVIDERS: "azure",
OPENCLAW_FAKE_CRABBOX_WHOAMI_STATUS: "1",
},
@ -1862,7 +1862,7 @@ describe("scripts/crabbox-wrapper", () => {
expect(result.stderr).toContain("selected=aws chain=azure,aws");
const invocations = readInvocations(invocationLog);
expect(invocations.filter(([command]) => command === "--version")).toEqual([["--version"]]);
expect(result.stderr).toContain("version=0.56.0");
expect(result.stderr).toContain("version=999.0.0");
expect(invocations.filter(([command]) => command === "doctor").map((args) => args[2])).toEqual([
"azure",
"aws",

View file

@ -492,7 +492,7 @@ process.exitCode = child.status ?? 1;
it("runs the checked-out Crabbox wrapper through its managed child", async () => {
await withShimFixture("scripts/crabbox-wrapper.mjs", async ({ fixtureRoot, runNode }) => {
const fixtureVersion = "0.56.0";
const fixtureVersion = "999.0.0";
const binDir = path.join(fixtureRoot, "fake bin");
const home = path.join(fixtureRoot, "home");
const state = path.join(fixtureRoot, "state");

View file

@ -23,7 +23,7 @@ suite.define(() => {
providerId: "crabbox",
machines: [{ id: "standard", label: "Standard", cpu: 32, memoryGb: 64, default: true }],
};
const disabledReason = "Upgrade Crabbox to 0.53.1 or newer, then restart the Gateway.";
const disabledReason = "Upgrade Crabbox to enable this operating system.";
const gateway = await installMockGateway(page, {
operatorScopes: ["operator.admin", "operator.read", "operator.write"],
workspaceGit: true,

View file

@ -609,7 +609,7 @@ describe("Where chip", () => {
);
it("hides unavailable operating systems from cloud configuration", () => {
const reason = "Upgrade Crabbox to 0.53.1 or newer, then restart the Gateway.";
const reason = "Upgrade Crabbox to enable this operating system.";
const container = renderPicker(true, undefined, {
cloudProfileId: "aws",
cloudProfiles: readDraftCloudProfiles([