From c2e5b3a67c3ff3b04f6e5fb71b684778bfef5d4a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 04:28:50 -0700 Subject: [PATCH] feat(ci): add workload-aware Crabbox routing --- package.json | 1 + scripts/crabbox-routing-policy.mjs | 70 ++++ scripts/crabbox-wrapper.mjs | 346 ++++++++++++++-- test/scripts/crabbox-routing-policy.test.ts | 94 +++++ test/scripts/crabbox-wrapper.test.ts | 423 +++++++++++++++++++- 5 files changed, 895 insertions(+), 39 deletions(-) create mode 100644 scripts/crabbox-routing-policy.mjs create mode 100644 test/scripts/crabbox-routing-policy.test.ts diff --git a/package.json b/package.json index 2b2d816841c5..51b6bb5431a0 100644 --- a/package.json +++ b/package.json @@ -345,6 +345,7 @@ "!docs/**/*.jpg", "!docs/**/*.png", "src/agents/templates/", + "scripts/crabbox-routing-policy.mjs", "scripts/crabbox-wrapper-providers.mjs", "scripts/crabbox-wrapper.mjs", "scripts/testbox-lease-freshness.mjs", diff --git a/scripts/crabbox-routing-policy.mjs b/scripts/crabbox-routing-policy.mjs new file mode 100644 index 000000000000..259ea1cda611 --- /dev/null +++ b/scripts/crabbox-routing-policy.mjs @@ -0,0 +1,70 @@ +const workloadAliases = new Map([ + ["check", "ci-fast"], + ["ci", "ci-fast"], + ["ci-fast", "ci-fast"], + ["ci-proof", "ci-proof"], + ["desktop", "desktop"], + ["interactive", "interactive"], + ["release", "release-proof"], + ["release-proof", "release-proof"], + ["untrusted", "untrusted"], + ["windows", "windows"], +]); + +export function normalizeCrabboxWorkload(value) { + const normalized = `${value ?? ""}`.trim().toLowerCase(); + if (!normalized) { + return ""; + } + return workloadAliases.get(normalized) ?? null; +} + +export function crabboxProviderChain({ + workload, + configuredProvider, + target, + advertisedProviders, +}) { + const providers = new Set(advertisedProviders); + const normalizedConfigured = `${configuredProvider ?? ""}`.trim(); + const normalizedTarget = `${target ?? ""}`.trim().toLowerCase(); + + if (normalizedTarget === "macos") { + return available(["aws"], providers); + } + if (normalizedTarget === "windows" || workload === "windows") { + return available(["azure", "aws"], providers); + } + + const cloudFallback = ["azure", "aws"]; + switch (workload) { + case "ci-fast": + return available(["blacksmith-testbox", "daytona", ...cloudFallback], providers); + case "ci-proof": + case "release-proof": + return available(["blacksmith-testbox", "daytona", ...cloudFallback], providers); + case "interactive": + return available(["daytona", ...cloudFallback], providers); + case "desktop": + return available(cloudFallback, providers); + case "untrusted": + // Daytona remains excluded until its brokered isolation profile has live proof. + return available(cloudFallback, providers); + default: + return available([normalizedConfigured], providers); + } +} + +export function selectReadyCrabboxProvider(chain, readiness) { + for (const provider of chain) { + const status = readiness.get(provider); + if (status?.ready) { + return { provider, readiness: status }; + } + } + return null; +} + +function available(candidates, advertisedProviders) { + return candidates.filter((provider) => provider && advertisedProviders.has(provider)); +} diff --git a/scripts/crabbox-wrapper.mjs b/scripts/crabbox-wrapper.mjs index ae6be01b1f95..5cde7151b207 100755 --- a/scripts/crabbox-wrapper.mjs +++ b/scripts/crabbox-wrapper.mjs @@ -21,6 +21,7 @@ import { homedir, tmpdir } from "node:os"; import { delimiter, dirname, extname, isAbsolute, relative, resolve } from "node:path"; import { StringDecoder } from "node:string_decoder"; import { fileURLToPath } from "node:url"; +import { crabboxProviderChain, normalizeCrabboxWorkload } from "./crabbox-routing-policy.mjs"; import { canonicalProviderName, isProviderAdvertised, @@ -54,11 +55,45 @@ const args = process.argv.slice(2); if (args[0] === "--") { args.shift(); } +const workloadOption = isWorkloadRoutedCommand(args) + ? extractWrapperValueOption(args, "--workload") + : undefined; const userArgStart = args[0] === "actions" && args[1] === "hydrate" ? 2 : 1; if (args[userArgStart] === "--") { args.splice(userArgStart, 1); } +function extractWrapperValueOption(commandArgs, name) { + const equalsPrefix = `${name}=`; + for (let index = 0; index < commandArgs.length; index += 1) { + const arg = commandArgs[index]; + if (arg === "--") { + break; + } + if (arg === name) { + const value = commandArgs[index + 1]; + if (!value || value === "--" || value.startsWith("-")) { + commandArgs.splice(index, 1); + return null; + } + commandArgs.splice(index, 2); + return value; + } + if (arg.startsWith(equalsPrefix)) { + commandArgs.splice(index, 1); + return arg.slice(equalsPrefix.length) || null; + } + } + return undefined; +} + +function isWorkloadRoutedCommand(commandArgs) { + return ( + ["run", "warmup"].includes(commandArgs[0]) || + (commandArgs[0] === "actions" && commandArgs[1] === "hydrate") + ); +} + function commandCandidates(command, platform) { if (platform !== "win32") { return [command]; @@ -232,6 +267,7 @@ const awsMacosPackageManagerScriptTargets = new Set([ "scripts/restart-mac.sh", ]); const minimumBlacksmithCrabboxVersion = [0, 22, 0]; +const minimumBrokeredDaytonaCrabboxVersion = [0, 40, 0]; const shellControlCommandPrefixes = new Set([ "if", "while", @@ -479,6 +515,27 @@ function gitOutput(commandArgs) { }; } +let resolvedCrabboxConfigCache; + +function resolvedCrabboxConfig() { + if (resolvedCrabboxConfigCache !== undefined) { + return resolvedCrabboxConfigCache; + } + const result = checkedOutput(binary, ["config", "show", "--json"]); + if (result.status !== 0) { + resolvedCrabboxConfigCache = null; + return resolvedCrabboxConfigCache; + } + try { + const parsed = JSON.parse(result.stdout || result.text); + resolvedCrabboxConfigCache = + parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null; + } catch { + resolvedCrabboxConfigCache = null; + } + return resolvedCrabboxConfigCache; +} + function envProvider() { const envProviderValue = process.env.CRABBOX_PROVIDER?.trim(); if (envProviderValue) { @@ -488,6 +545,10 @@ function envProvider() { } function configProvider() { + const resolved = resolvedCrabboxConfig()?.provider; + if (typeof resolved === "string" && resolved.trim()) { + return resolved.trim(); + } try { const config = readFileSync(resolve(repoRoot, ".crabbox.yaml"), "utf8"); const match = config.match(/^provider:\s*([^\s#]+)/m); @@ -501,6 +562,26 @@ function configuredProvider() { return envProvider() || configProvider(); } +function effectiveTargetContext(commandArgs) { + const config = resolvedCrabboxConfig(); + const configuredTarget = typeof config?.target === "string" ? config.target.trim() : ""; + const configuredWindowsMode = + typeof config?.windowsMode === "string" ? config.windowsMode.trim() : ""; + return { + target: ( + optionValue(commandArgs, "--target") || + process.env.CRABBOX_TARGET?.trim() || + process.env.CRABBOX_TARGET_OS?.trim() || + configuredTarget + ).toLowerCase(), + windowsMode: ( + optionValue(commandArgs, "--windows-mode") || + process.env.CRABBOX_WINDOWS_MODE?.trim() || + configuredWindowsMode + ).toLowerCase(), + }; +} + const runValueOptions = new Set([ "allow-env", "artifact-glob", @@ -682,23 +763,165 @@ function commandProvider(commandArgsInput) { return ""; } -function selectedProvider(commandArgs, advertisedProviders = []) { +function selectedProvider(commandArgs, advertisedProviders = [], versionText = "") { + const targetContext = effectiveTargetContext(commandArgs); + if (workloadOption === null) { + return { + provider: "", + source: "policy", + workload: "", + chain: [], + error: "--workload requires a value", + }; + } + const workload = requestedWorkload(commandArgs); + if (workload === null) { + return { + provider: "", + source: "policy", + workload: workloadOption ?? process.env.OPENCLAW_CRABBOX_WORKLOAD ?? "", + chain: [], + error: `unsupported Crabbox workload ${JSON.stringify(workloadOption ?? process.env.OPENCLAW_CRABBOX_WORKLOAD)}`, + }; + } + if (workload === "windows" && targetContext.target !== "windows") { + return { + provider: "", + source: "policy", + workload, + chain: [], + error: "Crabbox workload=windows requires target=windows", + }; + } const explicitProvider = commandProvider(commandArgs); if (explicitProvider) { - return explicitProvider; + return { provider: explicitProvider, source: "explicit", workload: "", chain: [] }; } - if (shouldPreferAzureForWindows(commandArgs, advertisedProviders)) { - return "azure"; + const environmentProvider = envProvider(); + if (environmentProvider) { + return { provider: environmentProvider, source: "environment", workload: "", chain: [] }; } - return configuredProvider(); + if (workload && hasOption(commandArgs, "--id")) { + return { + provider: "", + source: "policy", + workload, + chain: [], + error: + "reusing a workload-routed lease with --id requires --provider (or CRABBOX_PROVIDER) from the originating route", + }; + } + const configured = canonicalProviderName(configProvider()); + if (!workload && shouldPreferAzureForWindows(commandArgs, advertisedProviders)) { + return { provider: "azure", source: "windows-default", workload: "", chain: [] }; + } + if (!workload) { + return { provider: configured, source: "config", workload: "", chain: [] }; + } + + const chain = crabboxProviderChain({ + workload, + configuredProvider: configured, + target: targetContext.target, + advertisedProviders: advertisedProviders.map(canonicalProviderName), + }); + const readiness = new Map(); + let selectedProviderName = ""; + for (const candidate of chain) { + const status = crabboxProviderReadiness(candidate, versionText, targetContext); + readiness.set(candidate, status); + if (status.ready) { + selectedProviderName = candidate; + break; + } + } + if (!selectedProviderName) { + return { + provider: "", + source: "policy", + workload, + chain, + readiness, + error: `no ready provider for workload=${workload}`, + }; + } + return { + provider: selectedProviderName, + source: "policy", + workload, + chain, + readiness, + }; } -function shouldRequireBrokeredAws(commandArgs, providerName) { - if (process.env.OPENCLAW_CRABBOX_ALLOW_DIRECT_AWS === "1") { +function requestedWorkload(commandArgs) { + if (!isWorkloadRoutedCommand(commandArgs)) { + return ""; + } + const raw = workloadOption ?? process.env.OPENCLAW_CRABBOX_WORKLOAD?.trim() ?? ""; + if (!raw) { + return ""; + } + return normalizeCrabboxWorkload(raw); +} + +let managedBrokerAuthConfiguredCache; + +function crabboxProviderReadiness(providerName, versionText, targetContext) { + const canonicalProvider = canonicalProviderName(providerName); + if ( + canonicalProvider === "blacksmith-testbox" && + !satisfiesMinimumCrabboxVersion(versionText, minimumBlacksmithCrabboxVersion) + ) { + return { + ready: false, + reason: `requires Crabbox >= ${formatVersionTuple(minimumBlacksmithCrabboxVersion)} for Blacksmith Testbox`, + }; + } + if ( + canonicalProvider === "daytona" && + !directCloudOverrideEnabled(canonicalProvider) && + !satisfiesMinimumCrabboxVersion(versionText, minimumBrokeredDaytonaCrabboxVersion) + ) { + return { + ready: false, + reason: `requires Crabbox >= ${formatVersionTuple(minimumBrokeredDaytonaCrabboxVersion)} for brokered Daytona`, + }; + } + if ( + ["aws", "azure", "daytona"].includes(canonicalProvider) && + !directCloudOverrideEnabled(canonicalProvider) && + !managedBrokerAuthConfigured() + ) { + return { ready: false, reason: "managed Crabbox broker auth unavailable" }; + } + const doctorArgs = ["doctor", "--provider", canonicalProvider]; + if (targetContext.target) { + doctorArgs.push("--target", targetContext.target); + } + if (targetContext.target === "windows" && targetContext.windowsMode) { + doctorArgs.push("--windows-mode", targetContext.windowsMode); + } + doctorArgs.push("--json"); + const doctor = checkedOutput(binary, doctorArgs); + if (doctor.status !== 0) { + return { ready: false, reason: `doctor exited ${doctor.status}` }; + } + return { ready: true, reason: "doctor-ready" }; +} + +function formatProviderReadiness(readiness) { + return [...readiness.entries()] + .map(([candidate, status]) => `${candidate}:${status.ready ? "ready" : status.reason}`) + .join(","); +} + +function shouldRequireBrokeredCloud(commandArgs, providerName) { + if (directCloudOverrideEnabled(providerName)) { return false; } const canonicalProvider = canonicalProviderName(providerName); - if (canonicalProvider !== "aws") { + if (!["aws", "azure", "daytona"].includes(canonicalProvider)) { return false; } if (commandArgs[0] === "run" || commandArgs[0] === "warmup") { @@ -707,32 +930,59 @@ function shouldRequireBrokeredAws(commandArgs, providerName) { return commandArgs[0] === "actions" && commandArgs[1] === "hydrate"; } -function brokerAuthConfigured() { - const config = checkedOutput(binary, ["config", "show", "--json"]); - if (config.status !== 0) { - return false; - } - let parsed; - try { - parsed = JSON.parse(config.stdout || config.text); - } catch { - return false; - } - if (!parsed?.coordinator || parsed?.brokerAuth !== "configured") { - return false; - } - return checkedOutput(binary, ["whoami"]).status === 0; +function directCloudOverrideEnabled(providerName) { + return ( + process.env.OPENCLAW_CRABBOX_ALLOW_DIRECT_CLOUD === "1" || + (canonicalProviderName(providerName) === "aws" && + process.env.OPENCLAW_CRABBOX_ALLOW_DIRECT_AWS === "1") + ); } -function enforceBrokeredAws(commandArgs, providerName) { - if (!shouldRequireBrokeredAws(commandArgs, providerName) || brokerAuthConfigured()) { +function managedBrokerAuthConfigured() { + if (managedBrokerAuthConfiguredCache !== undefined) { + return managedBrokerAuthConfiguredCache; + } + const parsed = resolvedCrabboxConfig(); + if ( + !parsed?.coordinator || + parsed?.brokerMode !== "managed" || + parsed?.brokerAuth !== "configured" + ) { + managedBrokerAuthConfiguredCache = false; + return managedBrokerAuthConfiguredCache; + } + managedBrokerAuthConfiguredCache = checkedOutput(binary, ["whoami"]).status === 0; + return managedBrokerAuthConfiguredCache; +} + +function enforceBrokeredDaytonaVersion(commandArgs, providerName, versionText) { + if ( + canonicalProviderName(providerName) !== "daytona" || + !shouldRequireBrokeredCloud(commandArgs, providerName) || + satisfiesMinimumCrabboxVersion(versionText, minimumBrokeredDaytonaCrabboxVersion) + ) { return; } console.error( [ - "[crabbox] provider=aws requires a configured Crabbox broker for OpenClaw proof.", - "[crabbox] run `crabbox login --url https://crabbox.openclaw.ai --provider aws`, then retry.", - "[crabbox] for intentional direct AWS provider debugging, set OPENCLAW_CRABBOX_ALLOW_DIRECT_AWS=1.", + `[crabbox] provider=daytona requires Crabbox >= ${formatVersionTuple(minimumBrokeredDaytonaCrabboxVersion)} for brokered execution.`, + `[crabbox] selected binary reported version=${versionText || "unknown"}.`, + "[crabbox] update Crabbox, or set OPENCLAW_CRABBOX_ALLOW_DIRECT_CLOUD=1 only for intentional direct-provider debugging.", + ].join("\n"), + ); + process.exit(2); +} + +function enforceBrokeredCloud(commandArgs, providerName) { + if (!shouldRequireBrokeredCloud(commandArgs, providerName) || managedBrokerAuthConfigured()) { + return; + } + const canonicalProvider = canonicalProviderName(providerName); + console.error( + [ + `[crabbox] provider=${canonicalProvider} requires a configured managed Crabbox broker for OpenClaw proof.`, + "[crabbox] run `crabbox login --url https://crabbox.openclaw.ai`, then retry.", + "[crabbox] for intentional direct cloud debugging, set OPENCLAW_CRABBOX_ALLOW_DIRECT_CLOUD=1.", ].join("\n"), ); process.exit(2); @@ -800,6 +1050,21 @@ function ensureAzureWindowsProvider(commandArgs, providerName, advertisedProvide return normalizedArgs; } +function ensurePolicyProvider(commandArgs, selection) { + if ( + selection.source !== "policy" || + !selection.provider || + commandProvider(commandArgs) || + envProvider() + ) { + return commandArgs; + } + const normalizedArgs = [...commandArgs]; + const optionEnd = commandOptionEnd(normalizedArgs); + normalizedArgs.splice(optionEnd, 0, "--provider", selection.provider); + return normalizedArgs; +} + function ensureAwsMacOnDemandMarket(commandArgs, providerName) { if ( !["run", "warmup"].includes(commandArgs[0]) || @@ -3483,17 +3748,35 @@ const version = probeCrabboxMetadata(binary, ["--version"]); const help = probeCrabboxMetadata(binary, ["run", "--help"]); const providers = parseProvidersFromHelp(help.text); const displayBinary = binary === "crabbox" ? "crabbox" : relative(repoRoot, binary); -const provider = selectedProvider(args, providers); +const providerSelection = selectedProvider(args, providers, version.text); +if (providerSelection.error) { + console.error(`[crabbox] ${providerSelection.error}`); + if (providerSelection.readiness) { + console.error( + `[crabbox] provider readiness ${formatProviderReadiness(providerSelection.readiness)}`, + ); + } + process.exit(2); +} +const provider = providerSelection.provider; const canonicalProvider = canonicalProviderName(provider); const commandProviderValue = commandProvider(args); let normalizedArgs = ensureAwsMacOnDemandMarket( - ensureNativeWindowsHydrateJob(ensureAzureWindowsProvider(args, provider, providers)), + ensurePolicyProvider( + ensureNativeWindowsHydrateJob(ensureAzureWindowsProvider(args, provider, providers)), + providerSelection, + ), provider, ); console.error( `[crabbox] bin=${displayBinary} version=${version.text || "unknown"} provider=${provider || "unknown"} providers=${providers.join(",") || "unknown"}`, ); +if (providerSelection.source === "policy") { + console.error( + `[crabbox] route workload=${providerSelection.workload} selected=${provider} chain=${providerSelection.chain.join(",")} readiness=${formatProviderReadiness(providerSelection.readiness)}`, + ); +} if (version.status !== 0 || help.status !== 0) { console.error("[crabbox] selected binary failed basic --version/--help sanity checks"); @@ -3536,7 +3819,8 @@ if (canonicalProvider === "blacksmith-testbox") { } } -enforceBrokeredAws(normalizedArgs, provider); +enforceBrokeredDaytonaVersion(normalizedArgs, provider, version.text); +enforceBrokeredCloud(normalizedArgs, provider); if (canonicalProvider === "blacksmith-testbox") { const envProviderLocal = process.env.CRABBOX_PROVIDER?.trim(); diff --git a/test/scripts/crabbox-routing-policy.test.ts b/test/scripts/crabbox-routing-policy.test.ts new file mode 100644 index 000000000000..18c8433e7161 --- /dev/null +++ b/test/scripts/crabbox-routing-policy.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { + crabboxProviderChain, + normalizeCrabboxWorkload, + selectReadyCrabboxProvider, +} from "../../scripts/crabbox-routing-policy.mjs"; + +const advertisedProviders = ["aws", "azure", "blacksmith-testbox", "daytona"]; + +describe("Crabbox routing policy", () => { + it("normalizes supported workload aliases", () => { + expect(normalizeCrabboxWorkload("check")).toBe("ci-fast"); + expect(normalizeCrabboxWorkload("release")).toBe("release-proof"); + expect(normalizeCrabboxWorkload("unknown")).toBeNull(); + }); + + it("routes CI checks through Blacksmith, Daytona, Azure, then AWS", () => { + expect( + crabboxProviderChain({ + workload: "ci-fast", + configuredProvider: "blacksmith-testbox", + target: "linux", + advertisedProviders, + }), + ).toEqual(["blacksmith-testbox", "daytona", "azure", "aws"]); + }); + + it("does not let persistent cloud config outrank the CI policy", () => { + expect( + crabboxProviderChain({ + workload: "ci-proof", + configuredProvider: "aws", + target: "linux", + advertisedProviders, + }), + ).toEqual(["blacksmith-testbox", "daytona", "azure", "aws"]); + }); + + it("prefers Daytona for interactive Linux work", () => { + expect( + crabboxProviderChain({ + workload: "interactive", + configuredProvider: "blacksmith-testbox", + target: "linux", + advertisedProviders, + }), + ).toEqual(["daytona", "azure", "aws"]); + }); + + it("keeps untrusted work off Blacksmith and Daytona pending isolation proof", () => { + expect( + crabboxProviderChain({ + workload: "untrusted", + configuredProvider: "blacksmith-testbox", + target: "linux", + advertisedProviders, + }), + ).toEqual(["azure", "aws"]); + }); + + it("uses Azure then AWS for Windows and only AWS for macOS", () => { + expect( + crabboxProviderChain({ + workload: "ci-fast", + configuredProvider: "blacksmith-testbox", + target: "windows", + advertisedProviders, + }), + ).toEqual(["azure", "aws"]); + expect( + crabboxProviderChain({ + workload: "ci-fast", + configuredProvider: "blacksmith-testbox", + target: "macos", + advertisedProviders, + }), + ).toEqual(["aws"]); + }); + + it("selects the first ready provider without racing providers", () => { + const readiness = new Map([ + ["blacksmith-testbox", { ready: false, reason: "queued" }], + ["daytona", { ready: true, reason: "broker-ready" }], + ["azure", { ready: true, reason: "broker-ready" }], + ]); + + expect( + selectReadyCrabboxProvider(["blacksmith-testbox", "daytona", "azure"], readiness), + ).toEqual({ + provider: "daytona", + readiness: { ready: true, reason: "broker-ready" }, + }); + }); +}); diff --git a/test/scripts/crabbox-wrapper.test.ts b/test/scripts/crabbox-wrapper.test.ts index 0305172c20d5..622382d35a83 100644 --- a/test/scripts/crabbox-wrapper.test.ts +++ b/test/scripts/crabbox-wrapper.test.ts @@ -93,6 +93,36 @@ function writeFakeCrabbox(binDir: string, helpText: string): string { ` printf "%s" ${shellSingleQuote(helpText)}`, " exit 0", "fi", + 'if [ "$1" = "doctor" ]; then', + ' provider=""', + ' target=""', + ' windows_mode=""', + ' previous_arg=""', + ' for arg in "$@"; do', + ' if [ "$previous_arg" = "--provider" ] || [ "$previous_arg" = "-provider" ]; then provider="$arg"; fi', + ' if [ "$previous_arg" = "--target" ] || [ "$previous_arg" = "-target" ]; then target="$arg"; fi', + ' if [ "$previous_arg" = "--windows-mode" ] || [ "$previous_arg" = "-windows-mode" ]; then windows_mode="$arg"; fi', + ' case "$arg" in', + ' --provider=*|-provider=*) provider="${arg#*=}" ;;', + ' --target=*|-target=*) target="${arg#*=}" ;;', + ' --windows-mode=*|-windows-mode=*) windows_mode="${arg#*=}" ;;', + " esac", + ' previous_arg="$arg"', + " done", + ' if [ -n "${OPENCLAW_FAKE_CRABBOX_EXPECT_DOCTOR_TARGET:-}" ] && [ "$target" != "$OPENCLAW_FAKE_CRABBOX_EXPECT_DOCTOR_TARGET" ]; then', + ' printf "%s\\n" "doctor target mismatch: got=$target" >&2', + " exit 64", + " fi", + ' if [ -n "${OPENCLAW_FAKE_CRABBOX_EXPECT_DOCTOR_WINDOWS_MODE:-}" ] && [ "$windows_mode" != "$OPENCLAW_FAKE_CRABBOX_EXPECT_DOCTOR_WINDOWS_MODE" ]; then', + ' printf "%s\\n" "doctor windows mode mismatch: got=$windows_mode" >&2', + " exit 64", + " fi", + ' case ",${OPENCLAW_FAKE_CRABBOX_UNREADY_PROVIDERS:-}," in', + ' *,"$provider",*) printf "%s\\n" "{\\"ok\\":false,\\"provider\\":\\"$provider\\"}" >&2; exit 1 ;;', + " esac", + ' printf "%s\\n" "{\\"ok\\":true,\\"provider\\":\\"$provider\\"}"', + " exit 0", + "fi", 'if { [ "$1" = "run" ] || [ "$1" = "warmup" ]; } && { [ -n "${OPENCLAW_FAKE_CRABBOX_CLAIM_PATH:-}" ] || [ -n "${OPENCLAW_FAKE_CRABBOX_TIMING_LEASE_ID:-}" ]; }; then', ` ${shellSingleQuote(process.execPath)} --eval ${shellSingleQuote(stampClaimScript)}`, "fi", @@ -111,7 +141,7 @@ function writeFakeCrabbox(binDir: string, helpText: string): string { ' if [ -n "${OPENCLAW_FAKE_CRABBOX_CONFIG_JSON+x}" ]; then', ' printf "%s" "$OPENCLAW_FAKE_CRABBOX_CONFIG_JSON"', " else", - ' printf "%s" "{\\"coordinator\\":\\"configured-broker\\",\\"brokerAuth\\":\\"configured\\"}"', + ' printf "%s" "{\\"coordinator\\":\\"configured-broker\\",\\"brokerMode\\":\\"managed\\",\\"brokerAuth\\":\\"configured\\"}"', " fi", " exit 0", " fi", @@ -220,7 +250,7 @@ function writeFakeCrabbox(binDir: string, helpText: string): string { " process.stderr.write('config unavailable\\n');", " process.exit(status);", " }", - ' process.stdout.write(process.env.OPENCLAW_FAKE_CRABBOX_CONFIG_JSON || \'{"coordinator":"configured-broker","brokerAuth":"configured"}\');', + ' process.stdout.write(process.env.OPENCLAW_FAKE_CRABBOX_CONFIG_JSON || \'{"coordinator":"configured-broker","brokerMode":"managed","brokerAuth":"configured"}\');', " process.exit(0);", "}", 'if (args[0] === "whoami") {', @@ -265,6 +295,20 @@ function writeFakeCrabbox(binDir: string, helpText: string): string { ` process.stdout.write(${JSON.stringify(helpText)});`, " process.exit(0);", "}", + 'if (args[0] === "doctor") {', + " const optionValue = (name) => { const index = args.findIndex((arg) => arg === `--${name}` || arg === `-${name}`); const assigned = args.find((arg) => arg.startsWith(`--${name}=`) || arg.startsWith(`-${name}=`)); return index >= 0 ? args[index + 1] || '' : assigned?.slice(assigned.indexOf('=') + 1) || ''; };", + " const providerIndex = args.findIndex((arg) => arg === '--provider' || arg === '-provider');", + " const providerArg = args.find((arg) => arg.startsWith('--provider=') || arg.startsWith('-provider='));", + " const provider = providerIndex >= 0 ? args[providerIndex + 1] : providerArg?.slice(providerArg.indexOf('=') + 1) || '';", + " const target = optionValue('target');", + " const windowsMode = optionValue('windows-mode');", + " if (process.env.OPENCLAW_FAKE_CRABBOX_EXPECT_DOCTOR_TARGET && target !== process.env.OPENCLAW_FAKE_CRABBOX_EXPECT_DOCTOR_TARGET) { process.stderr.write(`doctor target mismatch: got=${target}\\n`); process.exit(64); }", + " if (process.env.OPENCLAW_FAKE_CRABBOX_EXPECT_DOCTOR_WINDOWS_MODE && windowsMode !== process.env.OPENCLAW_FAKE_CRABBOX_EXPECT_DOCTOR_WINDOWS_MODE) { process.stderr.write(`doctor windows mode mismatch: got=${windowsMode}\\n`); process.exit(64); }", + " const unready = new Set((process.env.OPENCLAW_FAKE_CRABBOX_UNREADY_PROVIDERS || '').split(',').filter(Boolean));", + " if (unready.has(provider)) { process.stderr.write(JSON.stringify({ ok: false, provider }) + '\\n'); process.exit(1); }", + " process.stdout.write(JSON.stringify({ ok: true, provider }) + '\\n');", + " process.exit(0);", + "}", `if (args[0] === "run" || args[0] === "warmup") { ${stampClaimScript} }`, 'if (args[0] === "run" && Number.parseInt(process.env.OPENCLAW_FAKE_CRABBOX_RUN_STATUS || "0", 10) !== 0) {', " process.stderr.write('fake run failure\\n');", @@ -566,7 +610,12 @@ function wrapperEnv(helpText: string, options: WrapperOptions): NodeJS.ProcessEn OPENCLAW_CRABBOX_SYNC_MIN_FREE_BYTES: "0", OPENCLAW_CRABBOX_WRAPPER_IGNORE_REPO_BINARY: "1", ...(options.configJson - ? { OPENCLAW_FAKE_CRABBOX_CONFIG_JSON: JSON.stringify(options.configJson) } + ? { + OPENCLAW_FAKE_CRABBOX_CONFIG_JSON: JSON.stringify({ + brokerMode: "managed", + ...options.configJson, + }), + } : {}), ...(options.configStatus ? { OPENCLAW_FAKE_CRABBOX_CONFIG_STATUS: String(options.configStatus) } @@ -829,6 +878,365 @@ describe("scripts/crabbox-wrapper", () => { runWrapper("provider: aws\n", ["--version"]); }); + it("routes CI workloads through the first ready provider", () => { + const result = runWrapper( + "provider: aws, azure, blacksmith-testbox, or daytona\n", + ["run", "--workload", "ci-fast", "--", "echo ok"], + { + env: { + OPENCLAW_FAKE_CRABBOX_VERSION: "crabbox 0.40.0", + OPENCLAW_FAKE_CRABBOX_UNREADY_PROVIDERS: "blacksmith-testbox", + }, + }, + ); + + expect(result.status).toBe(0); + expect(parseFakeCrabboxOutput(result).args).toContain("daytona"); + expect(result.stderr).toContain( + "route workload=ci-fast selected=daytona chain=blacksmith-testbox,daytona,azure,aws", + ); + }); + + it("uses brokered cloud providers as the final CI fallback", () => { + const result = runWrapper( + "provider: aws, azure, blacksmith-testbox, or daytona\n", + ["run", "--workload=ci-fast", "--", "echo ok"], + { + env: { + OPENCLAW_FAKE_CRABBOX_VERSION: "crabbox 0.40.0", + OPENCLAW_FAKE_CRABBOX_UNREADY_PROVIDERS: "blacksmith-testbox,daytona,azure", + }, + }, + ); + + expect(result.status).toBe(0); + expect(parseFakeCrabboxOutput(result).args).toContain("aws"); + expect(result.stderr).toContain("selected=aws"); + }); + + it("keeps the configured provider when no workload is requested", () => { + const result = runWrapper( + "provider: aws, azure, blacksmith-testbox, or daytona\n", + ["run", "--", "echo ok"], + { + configJson: { + provider: "aws", + target: "linux", + windowsMode: "normal", + coordinator: "configured-broker", + brokerMode: "managed", + brokerAuth: "configured", + }, + env: { OPENCLAW_FAKE_CRABBOX_VERSION: "crabbox 0.40.0" }, + }, + ); + + expect(result.status).toBe(0); + expect(parseFakeCrabboxOutput(result).args).not.toContain("--provider"); + expect(result.stderr).not.toContain("route workload="); + }); + + it("requires the originating provider when reusing a workload-routed lease", () => { + const result = runWrapper( + "provider: aws, azure, blacksmith-testbox, or daytona\n", + ["run", "--workload", "interactive", "--id", "cbx_existing", "--", "echo ok"], + { + env: { OPENCLAW_FAKE_CRABBOX_VERSION: "crabbox 0.40.0" }, + }, + ); + + expect(result.status).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain( + "reusing a workload-routed lease with --id requires --provider", + ); + }); + + it("reuses a workload-routed lease through its explicit provider", () => { + const result = runWrapper( + "provider: aws, azure, blacksmith-testbox, or daytona\n", + [ + "run", + "--provider", + "daytona", + "--workload", + "interactive", + "--id", + "cbx_existing", + "--", + "echo ok", + ], + { + env: { OPENCLAW_FAKE_CRABBOX_VERSION: "crabbox 0.40.0" }, + }, + ); + + expect(result.status).toBe(0); + expect(parseFakeCrabboxOutput(result).args).toContain("daytona"); + expect(result.stderr).not.toContain("route workload="); + }); + + it("routes configured macOS targets through AWS", () => { + const result = runWrapper( + "provider: aws, azure, blacksmith-testbox, or daytona\n", + ["run", "--workload", "ci-proof", "--", "echo ok"], + { + configJson: { + provider: "blacksmith-testbox", + target: "macos", + windowsMode: "normal", + coordinator: "configured-broker", + brokerAuth: "configured", + }, + env: { + OPENCLAW_FAKE_CRABBOX_VERSION: "crabbox 0.40.0", + OPENCLAW_FAKE_CRABBOX_EXPECT_DOCTOR_TARGET: "macos", + }, + }, + ); + + expect(result.status).toBe(0); + expect(parseFakeCrabboxOutput(result).args).toContain("aws"); + expect(result.stderr).toContain("chain=aws"); + }); + + it("probes native Windows readiness with the requested target context", () => { + const result = runWrapper( + "provider: aws, azure, blacksmith-testbox, or daytona\n", + [ + "run", + "--workload", + "ci-proof", + "--target", + "windows", + "--windows-mode", + "normal", + "--", + "echo ok", + ], + { + env: { + OPENCLAW_FAKE_CRABBOX_VERSION: "crabbox 0.40.0", + OPENCLAW_FAKE_CRABBOX_EXPECT_DOCTOR_TARGET: "windows", + OPENCLAW_FAKE_CRABBOX_EXPECT_DOCTOR_WINDOWS_MODE: "normal", + }, + }, + ); + + expect(result.status).toBe(0); + expect(parseFakeCrabboxOutput(result).args).toContain("azure"); + expect(result.stderr).toContain("chain=azure,aws"); + }); + + it("rejects the Windows workload without a Windows target", () => { + const result = runWrapper( + "provider: aws, azure, blacksmith-testbox, or daytona\n", + ["run", "--workload", "windows", "--", "echo ok"], + { env: { OPENCLAW_FAKE_CRABBOX_VERSION: "crabbox 0.40.0" } }, + ); + + expect(result.status).toBe(2); + expect(result.stderr).toContain("workload=windows requires target=windows"); + expect(result.stdout).toBe(""); + }); + + it("preserves following options when workload has no value", () => { + const result = runWrapper( + "provider: aws, azure, blacksmith-testbox, or daytona\n", + ["run", "--workload", "--target", "windows", "--", "echo ok"], + { env: { OPENCLAW_FAKE_CRABBOX_VERSION: "crabbox 0.40.0" } }, + ); + + expect(result.status).toBe(2); + expect(result.stderr).toContain("--workload requires a value"); + expect(result.stderr).not.toContain('unsupported Crabbox workload "--target"'); + expect(result.stdout).toBe(""); + }); + + it("rejects authenticated registered mode for broker-only cloud routing", () => { + const result = runWrapper( + "provider: aws, azure, blacksmith-testbox, or daytona\n", + ["run", "--workload", "desktop", "--", "echo ok"], + { + configJson: { + provider: "azure", + target: "linux", + windowsMode: "normal", + coordinator: "configured-broker", + brokerMode: "registered", + brokerAuth: "configured", + }, + env: { OPENCLAW_FAKE_CRABBOX_VERSION: "crabbox 0.40.0" }, + }, + ); + + expect(result.status).toBe(2); + expect(result.stderr).toContain("managed Crabbox broker auth unavailable"); + expect(result.stdout).toBe(""); + }); + + it("falls through Blacksmith when the Crabbox binary is too old", () => { + const result = runWrapper( + "provider: aws, azure, blacksmith-testbox, or daytona\n", + ["run", "--workload", "ci-fast", "--", "echo ok"], + { + env: { OPENCLAW_FAKE_CRABBOX_VERSION: "crabbox 0.21.9" }, + }, + ); + + expect(result.status).toBe(0); + expect(parseFakeCrabboxOutput(result).args).toContain("azure"); + expect(result.stderr).toContain( + "blacksmith-testbox:requires Crabbox >= 0.22.0 for Blacksmith Testbox", + ); + }); + + it("honors direct-cloud debugging during automatic readiness checks", () => { + const result = runWrapper( + "provider: aws, azure, blacksmith-testbox, or daytona\n", + ["run", "--workload", "desktop", "--", "echo ok"], + { + configJson: { coordinator: "", brokerAuth: "missing" }, + env: { + OPENCLAW_FAKE_CRABBOX_VERSION: "crabbox 0.40.0", + OPENCLAW_CRABBOX_ALLOW_DIRECT_CLOUD: "1", + }, + }, + ); + + expect(result.status).toBe(0); + expect(parseFakeCrabboxOutput(result).args).toContain("azure"); + expect(result.stderr).toContain("selected=azure"); + }); + + it("keeps workload configuration away from administrative commands", () => { + const result = runDefaultWrapper(["--version"], { + env: { OPENCLAW_CRABBOX_WORKLOAD: "ci-fast" }, + }); + + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("crabbox 0.22.1"); + expect(result.stderr).not.toContain("route workload="); + }); + + it("does not validate workload flags on administrative commands", () => { + const result = runDefaultWrapper(["--version", "--workload", "surprise"]); + + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("crabbox 0.22.1"); + expect(result.stderr).not.toContain("unsupported Crabbox workload"); + }); + + it("keeps explicit provider choices outside automatic routing", () => { + const result = runWrapper( + "provider: aws, azure, blacksmith-testbox, or daytona\n", + ["run", "--provider", "azure", "--workload", "interactive", "--", "echo ok"], + { + env: { + OPENCLAW_FAKE_CRABBOX_VERSION: "crabbox 0.40.0", + OPENCLAW_FAKE_CRABBOX_UNREADY_PROVIDERS: "azure", + }, + }, + ); + + expect(result.status).toBe(0); + expect(parseFakeCrabboxOutput(result).args).toContain("azure"); + expect(result.stderr).not.toContain("route workload="); + }); + + it("still validates workloads when a provider is explicit", () => { + const result = runWrapper("provider: aws, azure, blacksmith-testbox, or daytona\n", [ + "run", + "--provider", + "azure", + "--workload", + "surprise", + "--", + "echo ok", + ]); + + expect(result.status).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain('unsupported Crabbox workload "surprise"'); + }); + + it("requires a compatible Crabbox for explicit brokered Daytona runs", () => { + const result = runWrapper( + "provider: aws, azure, blacksmith-testbox, or daytona\n", + ["run", "--provider", "daytona", "--", "echo ok"], + { + env: { OPENCLAW_FAKE_CRABBOX_VERSION: "crabbox 0.39.9" }, + }, + ); + + expect(result.status).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain( + "provider=daytona requires Crabbox >= 0.40.0 for brokered execution", + ); + }); + + it("allows intentional direct Daytona debugging on older Crabbox versions", () => { + const result = runWrapper( + "provider: aws, azure, blacksmith-testbox, or daytona\n", + ["run", "--provider", "daytona", "--", "echo ok"], + { + configJson: { coordinator: "", brokerAuth: "missing" }, + env: { + OPENCLAW_FAKE_CRABBOX_VERSION: "crabbox 0.39.9", + OPENCLAW_CRABBOX_ALLOW_DIRECT_CLOUD: "1", + }, + }, + ); + + expect(result.status).toBe(0); + expect(parseFakeCrabboxOutput(result).args).toContain("daytona"); + }); + + it("allows automatic direct Daytona routing on older Crabbox versions", () => { + const result = runWrapper( + "provider: aws, azure, blacksmith-testbox, or daytona\n", + ["run", "--workload", "interactive", "--", "echo ok"], + { + configJson: { coordinator: "", brokerAuth: "missing" }, + env: { + OPENCLAW_FAKE_CRABBOX_VERSION: "crabbox 0.39.9", + OPENCLAW_CRABBOX_ALLOW_DIRECT_CLOUD: "1", + }, + }, + ); + + expect(result.status).toBe(0); + expect(parseFakeCrabboxOutput(result).args).toContain("daytona"); + expect(result.stderr).toContain("selected=daytona"); + }); + + it("fails closed when no policy provider is ready", () => { + const result = runWrapper( + "provider: aws, azure, blacksmith-testbox, or daytona\n", + ["run", "--workload", "ci-fast", "--", "echo ok"], + { + env: { + OPENCLAW_FAKE_CRABBOX_VERSION: "crabbox 0.40.0", + OPENCLAW_FAKE_CRABBOX_UNREADY_PROVIDERS: "blacksmith-testbox,daytona,azure,aws", + }, + }, + ); + + expect(result.status).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("no ready provider for workload=ci-fast"); + expect(result.stderr).toContain("provider readiness"); + }); + + it("rejects unknown workload policies before execution", () => { + const result = runDefaultWrapper(["run", "--workload", "surprise", "--", "echo ok"]); + + expect(result.status).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain('unsupported Crabbox workload "surprise"'); + }); + it("accepts advertised canonical providers from Crabbox help", () => { const result = runDefaultWrapper(["run", "--provider", "local-container", "--", "echo ok"]); @@ -1608,10 +2016,9 @@ describe("scripts/crabbox-wrapper", () => { expect(result.status).toBe(2); expect(result.stdout).toBe(""); - expect(result.stderr).toContain("provider=aws requires a configured Crabbox broker"); - expect(result.stderr).toContain( - "crabbox login --url https://crabbox.openclaw.ai --provider aws", - ); + 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).not.toContain("--provider aws"); }); it("fails closed for AWS proof when broker auth is stale", () => { @@ -1622,7 +2029,7 @@ describe("scripts/crabbox-wrapper", () => { expect(result.status).toBe(2); expect(result.stdout).toBe(""); - expect(result.stderr).toContain("provider=aws requires a configured Crabbox broker"); + expect(result.stderr).toContain("provider=aws requires a configured managed Crabbox broker"); }); it("allows explicit direct AWS debugging without broker auth", () => {