From 99acca309c3c0fc3d2f3e609400381ef5ff07aec Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 18 Aug 2026 13:47:51 -0700 Subject: [PATCH] fix(crabbox): surface invalid cloud worker profiles (#125991) --- .../src/crabbox-worker-provider.test.ts | 115 +--------- .../crabbox/src/crabbox-worker-provider.ts | 7 +- extensions/crabbox/src/doctor.test.ts | 205 ++++++++++++++++++ extensions/crabbox/src/doctor.ts | 55 ++++- 4 files changed, 267 insertions(+), 115 deletions(-) create mode 100644 extensions/crabbox/src/doctor.test.ts diff --git a/extensions/crabbox/src/crabbox-worker-provider.test.ts b/extensions/crabbox/src/crabbox-worker-provider.test.ts index 31dce15c51a7..6787aa8fc804 100644 --- a/extensions/crabbox/src/crabbox-worker-provider.test.ts +++ b/extensions/crabbox/src/crabbox-worker-provider.test.ts @@ -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" } }, diff --git a/extensions/crabbox/src/crabbox-worker-provider.ts b/extensions/crabbox/src/crabbox-worker-provider.ts index dcdbe9b89b0b..b77468533aba 100644 --- a/extensions/crabbox/src/crabbox-worker-provider.ts +++ b/extensions/crabbox/src/crabbox-worker-provider.ts @@ -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 { + 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(); + } + }); +}); diff --git a/extensions/crabbox/src/doctor.ts b/extensions/crabbox/src/doctor.ts index 75ff645d99e2..d2199ad573aa 100644 --- a/extensions/crabbox/src/doctor.ts +++ b/extensions/crabbox/src/doctor.ts @@ -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>(); 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}".`, + ), + }; + }, }; }