fix(crabbox): surface invalid cloud worker profiles (#125991)

This commit is contained in:
Peter Steinberger
2026-08-18 13:47:51 -07:00
committed by GitHub
parent f97a133254
commit 99acca309c
4 changed files with 267 additions and 115 deletions
@@ -1,5 +1,4 @@
import path from "node:path";
import type { HealthCheck } from "openclaw/plugin-sdk/health";
import type { WorkerProfile, WorkerProvider } from "openclaw/plugin-sdk/plugin-entry";
import * as processRuntime from "openclaw/plugin-sdk/process-runtime";
import type { SpawnResult } from "openclaw/plugin-sdk/process-runtime";
@@ -11,10 +10,6 @@ import {
resolveCrabboxBinary,
} from "./crabbox-worker-profile.js";
import { createCrabboxWorkerProvider, resolveOpenClawRoot } from "./crabbox-worker-provider.js";
import {
CRABBOX_CLOUD_WORKER_PROFILE_CHECK_ID,
registerCrabboxWorkerProviderDoctorChecks,
} from "./doctor.js";
const OPERATION_ID = `provision:v2:${"0".repeat(64)}`;
const LEASE_ID = "cbx_6071fc2062a6";
@@ -893,6 +888,9 @@ describe("Crabbox worker provider", () => {
expect(String(calls[2]?.options.input)).toContain(
'npx --yes --package "$package_spec" -- openclaw',
);
expect(String(calls[2]?.options.input)).toContain(
"OpenClaw worker bootstrap could not install Gateway version 2026.8.1",
);
expect(String(calls[2]?.options.input)).toContain(
'connect --target-file "$setup_code_file" --ephemeral',
);
@@ -1715,113 +1713,6 @@ describe("Crabbox binary resolution", () => {
});
});
function captureCrabboxDoctorCheck(): HealthCheck {
let check: HealthCheck | undefined;
registerCrabboxWorkerProviderDoctorChecks({
openclawRoot: OPENCLAW_ROOT,
registerHealthCheck(value) {
check = value;
},
});
if (!check) {
throw new Error("Crabbox doctor check was not registered");
}
return check;
}
describe("Crabbox worker doctor", () => {
it("reports a configured non-executable binary with a profile-specific repair", async () => {
const probe = vi.spyOn(doctorRuntime, "probeCrabboxVersion");
const binary = path.resolve(path.sep, "nonexistent", "crabbox");
try {
await expect(
captureCrabboxDoctorCheck().detect({
cfg: {
cloudWorkers: {
profiles: {
aws: { provider: "crabbox", settings: { binary } },
},
},
},
env: { PATH: "" },
} as never),
).resolves.toEqual([
expect.objectContaining({
checkId: CRABBOX_CLOUD_WORKER_PROFILE_CHECK_ID,
severity: "warning",
message: expect.stringContaining('profile "aws"'),
path: binary,
target: "aws",
fixHint: expect.stringContaining("cloudWorkers.profiles.aws.settings.binary"),
}),
]);
expect(probe).not.toHaveBeenCalled();
} finally {
probe.mockRestore();
}
});
it("emits no finding for a supported configured binary", async () => {
const probe = vi
.spyOn(doctorRuntime, "probeCrabboxVersion")
.mockResolvedValue({ status: "supported", version: "0.41.6" });
try {
await expect(
captureCrabboxDoctorCheck().detect({
cfg: {
cloudWorkers: {
profiles: {
aws: { provider: "crabbox", settings: { binary: process.execPath } },
},
},
},
} as never),
).resolves.toEqual([]);
expect(probe).toHaveBeenCalledOnce();
} finally {
probe.mockRestore();
}
});
it("reports an indeterminate version probe without asserting failure", async () => {
const probe = vi.spyOn(doctorRuntime, "probeCrabboxVersion").mockResolvedValue({
status: "indeterminate",
reason: "version command timed out after 2000 ms",
});
try {
await expect(
captureCrabboxDoctorCheck().detect({
cfg: {
cloudWorkers: {
profiles: {
aws: { provider: "crabbox", settings: { binary: process.execPath } },
},
},
},
} as never),
).resolves.toEqual([
expect.objectContaining({
severity: "info",
message: expect.stringContaining("could not determine its version"),
fixHint: expect.stringContaining(`${process.execPath} --version`),
}),
]);
} finally {
probe.mockRestore();
}
});
it("does not probe when no Crabbox cloud worker profile is configured", async () => {
const probe = vi.spyOn(doctorRuntime, "probeCrabboxVersion");
try {
await expect(captureCrabboxDoctorCheck().detect({ cfg: {} } as never)).resolves.toEqual([]);
expect(probe).not.toHaveBeenCalled();
} finally {
probe.mockRestore();
}
});
});
describe("Crabbox version probe", () => {
it.each([
{ output: "0.41.1\n", expected: { status: "supported", version: "0.41.1" } },
@@ -289,7 +289,12 @@ function nodeEnrollmentSetupCommand(params: {
" fi",
" done",
"fi",
'test -s "$package_spec_file"',
'if [ ! -s "$package_spec_file" ]; then',
` printf "%s\\n" ${shellQuote(
`OpenClaw worker bootstrap could not install Gateway version ${enrollment.openclawVersion}; for an unreleased Gateway build, cloudWorkers profile setup must install that exact version globally before enrollment.`,
)} >&2`,
" exit 1",
"fi",
'package_spec="$(cat "$package_spec_file")"',
'if [ "$package_spec" = "@global" ]; then',
` setsid -f sh -c 'printf "%s\\n" "$$" >"$1"; shift; exec "$@"' sh "$pid_file" env OPENCLAW_STATE_DIR="$state_dir" openclaw ${launch} >"$state_dir/node.log" 2>&1 </dev/null`,
+205
View File
@@ -0,0 +1,205 @@
import path from "node:path";
import type { HealthCheck } from "openclaw/plugin-sdk/health";
import { describe, expect, it, vi } from "vitest";
import * as doctorRuntime from "./crabbox-worker-doctor-runtime.js";
import {
CRABBOX_CLOUD_WORKER_PROFILE_CHECK_ID,
registerCrabboxWorkerProviderDoctorChecks,
} from "./doctor.js";
const OPENCLAW_ROOT = path.resolve(path.sep, "workspace", "openclaw");
function captureCrabboxDoctorCheck(): HealthCheck {
let check: HealthCheck | undefined;
registerCrabboxWorkerProviderDoctorChecks({
openclawRoot: OPENCLAW_ROOT,
registerHealthCheck(value) {
check = value;
},
});
if (!check) {
throw new Error("Crabbox doctor check was not registered");
}
return check;
}
describe("Crabbox worker doctor", () => {
it("reports a configured non-executable binary with a profile-specific repair", async () => {
const probe = vi.spyOn(doctorRuntime, "probeCrabboxVersion");
const binary = path.resolve(path.sep, "nonexistent", "crabbox");
try {
await expect(
captureCrabboxDoctorCheck().detect({
cfg: {
cloudWorkers: {
profiles: {
aws: { provider: "crabbox", settings: { binary } },
},
},
},
env: { PATH: "" },
} as never),
).resolves.toEqual([
expect.objectContaining({
checkId: CRABBOX_CLOUD_WORKER_PROFILE_CHECK_ID,
severity: "warning",
message: expect.stringContaining('profile "aws"'),
path: binary,
target: "aws",
fixHint: expect.stringContaining("cloudWorkers.profiles.aws.settings.binary"),
}),
]);
expect(probe).not.toHaveBeenCalled();
} finally {
probe.mockRestore();
}
});
it("emits no finding for a supported configured binary", async () => {
const probe = vi
.spyOn(doctorRuntime, "probeCrabboxVersion")
.mockResolvedValue({ status: "supported", version: "0.41.6" });
try {
await expect(
captureCrabboxDoctorCheck().detect({
cfg: {
cloudWorkers: {
profiles: {
aws: { provider: "crabbox", settings: { binary: process.execPath } },
},
},
},
} as never),
).resolves.toEqual([]);
expect(probe).toHaveBeenCalledOnce();
} finally {
probe.mockRestore();
}
});
it("reports an indeterminate version probe without asserting failure", async () => {
const probe = vi.spyOn(doctorRuntime, "probeCrabboxVersion").mockResolvedValue({
status: "indeterminate",
reason: "version command timed out after 2000 ms",
});
try {
await expect(
captureCrabboxDoctorCheck().detect({
cfg: {
cloudWorkers: {
profiles: {
aws: { provider: "crabbox", settings: { binary: process.execPath } },
},
},
},
} as never),
).resolves.toEqual([
expect.objectContaining({
severity: "info",
message: expect.stringContaining("could not determine its version"),
fixHint: expect.stringContaining(`${process.execPath} --version`),
}),
]);
} finally {
probe.mockRestore();
}
});
it("detects and removes only stale Crabbox desktop settings", async () => {
const probe = vi
.spyOn(doctorRuntime, "probeCrabboxVersion")
.mockResolvedValue({ status: "supported", version: "0.41.6" });
const cfg = {
cloudWorkers: {
desktop: true,
profiles: {
aws: {
provider: "crabbox",
install: "npm",
settings: { binary: process.execPath, class: "fast", desktop: true, ttl: "12h" },
},
secondary: {
provider: " CRABBOX ",
settings: { binary: process.execPath, desktop: true, idleTimeout: "30m" },
},
disabled: {
provider: "crabbox",
settings: { binary: process.execPath, desktop: false },
},
absent: { provider: "crabbox", settings: { binary: process.execPath } },
malformed: {
provider: "crabbox",
settings: { binary: process.execPath, desktop: "true" },
},
other: { provider: "device", settings: { desktop: true } },
},
},
} as const;
const before = structuredClone(cfg);
const check = captureCrabboxDoctorCheck();
try {
const findings = await check.detect({ cfg } as never);
expect(findings).toEqual([
expect.objectContaining({
checkId: CRABBOX_CLOUD_WORKER_PROFILE_CHECK_ID,
severity: "warning",
message: expect.stringContaining("node transport no longer supports desktop profiles"),
ocPath: "cloudWorkers.profiles.aws.settings.desktop",
target: "aws",
fixHint: expect.stringContaining("openclaw doctor --fix"),
}),
expect.objectContaining({
ocPath: "cloudWorkers.profiles.secondary.settings.desktop",
target: "secondary",
}),
]);
const repaired = await check.repair!({ cfg, dryRun: true } as never, findings);
expect(cfg).toEqual(before);
expect(repaired.config).toEqual({
cloudWorkers: {
desktop: true,
profiles: {
aws: {
provider: "crabbox",
install: "npm",
settings: { binary: process.execPath, class: "fast", ttl: "12h" },
},
secondary: {
provider: " CRABBOX ",
settings: { binary: process.execPath, idleTimeout: "30m" },
},
disabled: {
provider: "crabbox",
settings: { binary: process.execPath, desktop: false },
},
absent: { provider: "crabbox", settings: { binary: process.execPath } },
malformed: {
provider: "crabbox",
settings: { binary: process.execPath, desktop: "true" },
},
other: { provider: "device", settings: { desktop: true } },
},
},
});
expect(repaired.changes).toHaveLength(2);
await expect(check.repair!({ cfg: repaired.config } as never, findings)).resolves.toEqual({
config: repaired.config,
changes: [],
});
} finally {
probe.mockRestore();
}
});
it("does not probe when no Crabbox cloud worker profile is configured", async () => {
const probe = vi.spyOn(doctorRuntime, "probeCrabboxVersion");
try {
await expect(captureCrabboxDoctorCheck().detect({ cfg: {} } as never)).resolves.toEqual([]);
expect(probe).not.toHaveBeenCalled();
} finally {
probe.mockRestore();
}
});
});
+53 -2
View File
@@ -44,7 +44,7 @@ function createCrabboxCloudWorkerProfileCheck(openclawRoot: string): HealthCheck
return {
id: CRABBOX_CLOUD_WORKER_PROFILE_CHECK_ID,
kind: "plugin",
description: "Verify configured Crabbox cloud worker binaries before dispatch.",
description: "Verify configured Crabbox cloud worker profiles before dispatch.",
source: "crabbox",
async detect(ctx) {
const profiles = Object.entries(ctx.cfg.cloudWorkers?.profiles ?? {}).filter(
@@ -56,7 +56,21 @@ function createCrabboxCloudWorkerProfileCheck(openclawRoot: string): HealthCheck
const probes = new Map<string, ReturnType<typeof doctorRuntime.probeCrabboxVersion>>();
const findings: HealthFinding[] = [];
for (const [profileId, profile] of profiles) {
const explicitBinary = nonEmptyString(readRecord(profile.settings)?.binary);
const settings = readRecord(profile.settings);
if (settings?.desktop === true) {
const configPath = `cloudWorkers.profiles.${profileId}.settings.desktop`;
findings.push({
checkId: CRABBOX_CLOUD_WORKER_PROFILE_CHECK_ID,
severity: "warning",
source: "crabbox",
message: `Cloud worker profile "${profileId}" requests a desktop, but Crabbox node transport no longer supports desktop profiles.`,
ocPath: configPath,
target: profileId,
requirement: "a Crabbox cloud worker profile without desktop mode",
fixHint: `Run \`openclaw doctor --fix\` to remove the stale ${configPath} setting.`,
});
}
const explicitBinary = nonEmptyString(settings?.binary);
const binary = findCrabboxBinary({
...(explicitBinary ? { explicit: explicitBinary } : {}),
openclawRoot,
@@ -104,6 +118,43 @@ function createCrabboxCloudWorkerProfileCheck(openclawRoot: string): HealthCheck
}
return findings;
},
async repair(ctx, findings) {
const profileIds = new Set(
findings.flatMap((entry) => {
const profileId = entry.target;
return entry.checkId === CRABBOX_CLOUD_WORKER_PROFILE_CHECK_ID &&
profileId &&
entry.ocPath === `cloudWorkers.profiles.${profileId}.settings.desktop`
? [profileId]
: [];
}),
);
const staleProfileIds = [...profileIds].filter((profileId) => {
const profile = ctx.cfg.cloudWorkers?.profiles?.[profileId];
return (
profile?.provider.trim().toLowerCase() === CRABBOX_WORKER_PROVIDER_ID &&
readRecord(profile.settings)?.desktop === true
);
});
if (staleProfileIds.length === 0) {
return { config: ctx.cfg, changes: [] };
}
const config = structuredClone(ctx.cfg);
for (const profileId of staleProfileIds) {
const settings = readRecord(config.cloudWorkers?.profiles?.[profileId]?.settings);
if (settings) {
delete settings.desktop;
}
}
return {
config,
changes: staleProfileIds.map(
(profileId) =>
`Removed stale cloudWorkers.profiles.${profileId}.settings.desktop from Crabbox profile "${profileId}".`,
),
};
},
};
}