From 89e4ef2ab7214cefbd98c4baf018392da107a20c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 05:39:50 -0700 Subject: [PATCH] fix(ci): preserve routing failure diagnostics --- scripts/crabbox-wrapper.mjs | 67 ++++++++++++++++++++++++---- test/scripts/crabbox-wrapper.test.ts | 22 ++++++++- 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/scripts/crabbox-wrapper.mjs b/scripts/crabbox-wrapper.mjs index 3e8a55687174..c7386ddacd22 100755 --- a/scripts/crabbox-wrapper.mjs +++ b/scripts/crabbox-wrapper.mjs @@ -431,6 +431,21 @@ function checkedOutput( }; } +function recoveryCommand(commandArgs) { + return [binary, ...commandArgs].map(recoveryCommandArgument).join(" "); +} + +function recoveryCommandArgument(value) { + const text = `${value}`; + if (/^[A-Za-z0-9_./:@%+=,-]+$/u.test(text)) { + return text; + } + if (process.platform === "win32") { + return `"${text.replaceAll('"', '""')}"`; + } + return `'${text.replaceAll("'", "'\\''")}'`; +} + // Probe Crabbox metadata (`--version` / `run --help`) with one generous retry. // A cold Crabbox can be SIGKILLed by the snappy default timeout or emit nothing // on the first call, then be instant and clean on the next. Retrying keeps the @@ -898,6 +913,7 @@ function crabboxProviderReadiness(providerName, versionText, targetContext) { return { ready: false, reason: `requires Crabbox >= ${formatVersionTuple(minimumBlacksmithCrabboxVersion)} for Blacksmith Testbox`, + recovery: "update Crabbox, then retry", }; } if ( @@ -907,10 +923,15 @@ function crabboxProviderReadiness(providerName, versionText, targetContext) { return { ready: false, reason: `requires Crabbox >= ${formatVersionTuple(minimumBrokeredDaytonaCrabboxVersion)} for brokered Daytona`, + recovery: "update Crabbox, then retry", }; } if (["aws", "azure", "daytona"].includes(canonicalProvider) && !managedBrokerAuthConfigured()) { - return { ready: false, reason: "managed Crabbox broker auth unavailable" }; + return { + ready: false, + reason: "managed Crabbox broker auth unavailable", + recovery: `run \`${recoveryCommand(["login", "--url", "https://crabbox.openclaw.ai"])}\`, then retry`, + }; } const doctorArgs = ["doctor", "--provider", canonicalProvider]; if (targetContext.target) { @@ -922,17 +943,40 @@ function crabboxProviderReadiness(providerName, versionText, targetContext) { doctorArgs.push("--json"); const doctor = checkedOutput(binary, doctorArgs); if (doctor.status !== 0) { - return { ready: false, reason: `doctor exited ${doctor.status}` }; + const diagnostic = compactDiagnosticText(doctor.text); + return { + ready: false, + reason: `doctor exited ${doctor.status}${diagnostic ? `: ${diagnostic}` : ""}`, + recovery: `run \`${recoveryCommand(doctorArgs)}\``, + }; } return { ready: true, reason: "doctor-ready" }; } +function compactDiagnosticText(value, maxLength = 500) { + const compact = `${value ?? ""}`.replace(/\s+/gu, " ").trim(); + if (compact.length <= maxLength) { + return compact; + } + return `${compact.slice(0, Math.max(0, maxLength - 3))}...`; +} + function formatProviderReadiness(readiness) { return [...readiness.entries()] .map(([candidate, status]) => `${candidate}:${status.ready ? "ready" : status.reason}`) .join(","); } +function providerRecoveryAdvice(readiness) { + return [ + ...new Set( + [...readiness.values()] + .map((status) => status.recovery) + .filter((recovery) => typeof recovery === "string" && recovery.length > 0), + ), + ]; +} + function shouldRequireBrokeredCloud(commandArgs, providerName, explicitProviderRequested = false) { const canonicalProvider = canonicalProviderName(providerName); if (!["aws", "azure", "daytona"].includes(canonicalProvider)) { @@ -1012,7 +1056,7 @@ function enforceBrokeredCloud(commandArgs, providerName, explicitProviderRequest const canonicalProvider = canonicalProviderName(providerName); const instructions = [ `[crabbox] provider=${canonicalProvider} requires a configured managed Crabbox broker for OpenClaw proof.`, - "[crabbox] run `crabbox login --url https://crabbox.openclaw.ai`, then retry.", + `[crabbox] run \`${recoveryCommand(["login", "--url", "https://crabbox.openclaw.ai"])}\`, then retry.`, ]; if (canonicalProvider !== "aws") { instructions.push( @@ -3783,6 +3827,15 @@ const version = probeCrabboxMetadata(binary, ["--version"]); const help = probeCrabboxMetadata(binary, ["run", "--help"]); const providers = parseProvidersFromHelp(help.text); const displayBinary = binary === "crabbox" ? "crabbox" : relative(repoRoot, binary); + +if (version.status !== 0 || help.status !== 0) { + console.error( + `[crabbox] bin=${displayBinary} version=${version.text || "unknown"} providers=${providers.join(",") || "unknown"}`, + ); + console.error("[crabbox] selected binary failed basic --version/--help sanity checks"); + process.exit(2); +} + const providerSelection = selectedProvider(args, providers, version.text); if (providerSelection.error) { console.error(`[crabbox] ${providerSelection.error}`); @@ -3790,6 +3843,9 @@ if (providerSelection.error) { console.error( `[crabbox] provider readiness ${formatProviderReadiness(providerSelection.readiness)}`, ); + for (const recovery of providerRecoveryAdvice(providerSelection.readiness)) { + console.error(`[crabbox] recovery: ${recovery}`); + } } process.exit(2); } @@ -3813,11 +3869,6 @@ if (providerSelection.source === "policy") { ); } -if (version.status !== 0 || help.status !== 0) { - console.error("[crabbox] selected binary failed basic --version/--help sanity checks"); - process.exit(2); -} - if (provider && !isProviderAdvertised(provider, providers)) { if (providers.length === 0) { console.error( diff --git a/test/scripts/crabbox-wrapper.test.ts b/test/scripts/crabbox-wrapper.test.ts index 95320d676016..b152482aba46 100644 --- a/test/scripts/crabbox-wrapper.test.ts +++ b/test/scripts/crabbox-wrapper.test.ts @@ -1437,6 +1437,10 @@ describe("scripts/crabbox-wrapper", () => { expect(result.stdout).toBe(""); expect(result.stderr).toContain("no ready provider for workload=ci-fast"); expect(result.stderr).toContain("provider readiness"); + expect(result.stderr).toContain('{"ok":false,"provider":"blacksmith-testbox"}'); + expect(result.stderr).toMatch( + /recovery: run `\S+crabbox doctor --provider blacksmith-testbox --json`/u, + ); }); it("rejects unknown workload policies before execution", () => { @@ -2221,7 +2225,7 @@ describe("scripts/crabbox-wrapper", () => { expect(result.status).toBe(2); expect(result.stdout).toBe(""); expect(result.stderr).toContain("provider=aws requires a configured managed Crabbox broker"); - expect(result.stderr).toContain("crabbox login --url https://crabbox.openclaw.ai"); + expect(result.stderr).toContain("login --url https://crabbox.openclaw.ai"); expect(result.stderr).not.toContain("--provider aws"); expect(result.stderr).not.toContain("OPENCLAW_CRABBOX_ALLOW_DIRECT_CLOUD"); }); @@ -3788,6 +3792,22 @@ describe("scripts/crabbox-wrapper", () => { expect(result.stderr).toContain("selected binary failed basic --version/--help sanity checks"); }); + it("rejects a broken binary before workload provider discovery", () => { + const helpText = + "provider: hetzner, aws, local-container, blacksmith-testbox, daytona, azure, or cloudflare\n"; + const result = runWrapper(helpText, ["run", "--workload", "ci-fast", "--", "echo ok"], { + env: { OPENCLAW_TEST_CRABBOX_METADATA_PROBE_TIMEOUT_MS: "100" }, + extraPathEntries: [makeSlowVersionCrabbox(helpText)], + nodePreload: testTimingPreload({ spawnTimeoutMs: 25 }), + }); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(2); + expect(result.stderr).toContain("selected binary failed basic --version/--help sanity checks"); + expect(result.stderr).not.toContain("no ready provider"); + expect(result.stderr).not.toContain("provider readiness"); + }); + it("retries a cold Crabbox whose run --help is slower than the default probe timeout", () => { const helpText = "provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n"; // First probe is SIGKILLed at 25ms; the retry gets the full generous timeout