diff --git a/docs/gateway/cloud-workers.md b/docs/gateway/cloud-workers.md index b8c6c500d01b..e647a2004b09 100644 --- a/docs/gateway/cloud-workers.md +++ b/docs/gateway/cloud-workers.md @@ -181,7 +181,7 @@ openclaw gateway call sessions.dispatch \ ### Choose a machine class per session -A worker profile's `settings.class` remains its default. In the Control UI, selecting a **Cloud · profile** destination in the Place picker reveals a machine section listing the profile's advertised classes, each with a one-line description and the default marked; picking one updates the place chip (for example `hetzner · Fast`) and carries the choice into dispatch. To choose a different size for one new placement over RPC instead, pass `machineClass` with `profileId`: +A worker profile's `settings.class` remains its default. In the Control UI, selecting a **Cloud · profile** destination in the Place picker reveals a machine section listing the profile's advertised classes, with reported vCPU and RAM when available and the default marked; picking one updates the place chip (for example `hetzner · Fast`) and carries the choice into dispatch. To choose a different size for one new placement over RPC instead, pass `machineClass` with `profileId`: ```bash openclaw gateway call sessions.dispatch \ @@ -189,7 +189,7 @@ openclaw gateway call sessions.dispatch \ --params '{"key":"agent:main:big-refactor","profileId":"aws","machineClass":"large"}' ``` -The bundled Crabbox provider advertises `standard`, `fast`, `large`, and `beast` through `environments.list`. When the selected backend publishes machine shapes, the picker shows their vCPU and RAM; for AWS these classes are 32 vCPU · 64 GB, 64 vCPU · 128 GB, 96 vCPU · 192 GB, and 192 vCPU · 384 GB, respectively. You can also pass a provider-native server or instance type such as `c7a.24xlarge`; Crabbox treats any other non-empty class as that exact type. The selected value is fixed for that placement and reused by safe provisioning retries. `machineClass` is valid only with `profileId`, not `deviceId`. +The bundled Crabbox provider advertises whatever machine classes the configured Crabbox binary reports for the selected backend, preserving Crabbox's size order. For example, a catalog containing `tiny`, `small`, `standard`, `fast`, `large`, and `beast` produces those six picker rows in that order; if Crabbox reports `standard` as 32 vCPU · 64 GB, that shape appears beside the class. Older binaries that publish no matching class catalog retain the label-only `standard`, `fast`, `large`, and `beast` fallback. You can also pass a provider-native server or instance type such as `c7a.24xlarge`; Crabbox treats any other non-empty class as that exact type. The selected value is fixed for that placement and reused by safe provisioning retries. `machineClass` is valid only with `profileId`, not `deviceId`. `sessions.dispatch` closes local turn admission, drains active work, validates the eligible Git workspace inventory, provisions the lease, runs setup, enrolls the node, pushes the Gateway bundle, syncs the workspace, and returns once the placement reaches `active` ownership. Inventory validation happens before provider allocation and reports an invalid request with an actionable size or entry limit when the workspace cannot be dispatched. Budget several minutes for the first dispatch; leases and content-addressed bundles are reused where safe. After that, talk to the session as usual. OpenClaw turns route to the worker process; supported SSH-backed providers may still carry Codex remote-exec. diff --git a/extensions/crabbox/src/crabbox-worker-profile.ts b/extensions/crabbox/src/crabbox-worker-profile.ts index 408c4f94c842..3e2b081bd8c3 100644 --- a/extensions/crabbox/src/crabbox-worker-profile.ts +++ b/extensions/crabbox/src/crabbox-worker-profile.ts @@ -45,12 +45,9 @@ type CrabboxProfile = { setup?: string; }; -const CRABBOX_MACHINE_OPTIONS = [ - { id: "standard", label: "Standard" }, - { id: "fast", label: "Fast" }, - { id: "large", label: "Large" }, - { id: "beast", label: "Beast" }, -] as const; +const CRABBOX_FALLBACK_MACHINE_CLASSES = ["standard", "fast", "large", "beast"] as const; +const MAX_CRABBOX_MACHINE_CLASS_LENGTH = 128; +const MAX_CRABBOX_MACHINE_OPTIONS = 32; export type CrabboxMachineShape = Readonly<{ class: string; @@ -162,24 +159,40 @@ export function listCrabboxMachineOptions( configuredClass: string, shapes: readonly CrabboxMachineShape[] | undefined, ): readonly WorkerMachineOption[] { + const seen = new Set(); + const reportedShapes = shapes?.filter((shape) => { + if (shape.class.length > MAX_CRABBOX_MACHINE_CLASS_LENGTH || seen.has(shape.class)) { + return false; + } + seen.add(shape.class); + return true; + }); + const candidates: readonly CrabboxMachineShape[] = reportedShapes?.length + ? reportedShapes + : CRABBOX_FALLBACK_MACHINE_CLASSES.map((machineClass) => ({ class: machineClass })); + const catalogLimit = candidates + .slice(0, MAX_CRABBOX_MACHINE_OPTIONS) + .some((shape) => shape.class === configuredClass) + ? MAX_CRABBOX_MACHINE_OPTIONS + : MAX_CRABBOX_MACHINE_OPTIONS - 1; // Built by assignment rather than conditional spread: oxlint's no-map-spread // rejects spreading to shape objects inside a map callback. - const options = CRABBOX_MACHINE_OPTIONS.map((option) => { - const shape = shapes?.find((candidate) => candidate.class === option.id); + const options = candidates.slice(0, catalogLimit).map((shape) => { + const id = shape.class; const result: { id: string; label: string; cpu?: number; memoryGb?: number; default?: boolean; - } = { id: option.id, label: option.label }; + } = { id, label: id.replace(/^./u, (initial) => initial.toUpperCase()) }; if (shape?.cpu !== undefined) { result.cpu = shape.cpu; } if (shape?.memoryGb !== undefined) { result.memoryGb = shape.memoryGb; } - if (option.id === configuredClass) { + if (id === configuredClass) { result.default = true; } return result; diff --git a/extensions/crabbox/src/crabbox-worker-provider.test.ts b/extensions/crabbox/src/crabbox-worker-provider.test.ts index cee54b4dd301..0962c2ad1510 100644 --- a/extensions/crabbox/src/crabbox-worker-provider.test.ts +++ b/extensions/crabbox/src/crabbox-worker-provider.test.ts @@ -124,7 +124,7 @@ function hasLoneSurrogate(value: string): boolean { } describe("Crabbox worker provider", () => { - it("attaches provider machine shapes once and preserves a configured literal default", async () => { + it("derives ordered machine classes and shapes while preserving configured defaults", async () => { const calls: string[][] = []; const provider = providerWithRunner(async (argv) => { calls.push(argv); @@ -133,6 +133,8 @@ describe("Crabbox worker provider", () => { { provider: "aws", classes: [ + { class: "tiny", type: "c7a.2xlarge", vcpu: 8, memoryGb: 16 }, + { class: "small", type: "c7a.4xlarge", vcpu: 16, memoryGb: 32 }, { class: "standard", type: "c7a.8xlarge", vcpu: 32, memoryGb: 64 }, { class: "fast", type: "c7a.16xlarge", vcpu: 64, memoryGb: 128 }, { class: "large", type: "c7a.24xlarge", vcpu: 96, memoryGb: 192 }, @@ -145,6 +147,8 @@ describe("Crabbox worker provider", () => { expect(provider.supportedExecutionModes).toEqual(["worker-turn"]); expect(await provider.listMachineOptions?.(PROFILE)).toEqual([ + { id: "tiny", label: "Tiny", cpu: 8, memoryGb: 16 }, + { id: "small", label: "Small", cpu: 16, memoryGb: 32 }, { id: "standard", label: "Standard", @@ -157,6 +161,8 @@ describe("Crabbox worker provider", () => { { id: "beast", label: "Beast", cpu: 192, memoryGb: 384 }, ]); expect(await provider.listMachineOptions?.({ ...PROFILE, class: "c7a.24xlarge" })).toEqual([ + { id: "tiny", label: "Tiny", cpu: 8, memoryGb: 16 }, + { id: "small", label: "Small", cpu: 16, memoryGb: 32 }, { id: "standard", label: "Standard", cpu: 32, memoryGb: 64 }, { id: "fast", label: "Fast", cpu: 64, memoryGb: 128 }, { id: "large", label: "Large", cpu: 96, memoryGb: 192 }, @@ -171,6 +177,33 @@ describe("Crabbox worker provider", () => { expect(calls.filter((argv) => argv[1] === "providers")).toHaveLength(1); }); + it("bounds and filters malformed catalogs before gateway normalization", async () => { + const invalidClass = "x".repeat(129); + const classes = [ + { class: invalidClass, vcpu: 1, memoryGb: 2 }, + ...Array.from({ length: 40 }, (_, index) => ({ + class: `class-${String(index).padStart(2, "0")}`, + vcpu: index === 0 ? 0 : index + 1, + memoryGb: index === 0 ? 1.5 : (index + 1) * 2, + })), + ]; + const provider = providerWithRunner(async () => + commandResult({ stdout: JSON.stringify([{ provider: "aws", classes }]) }), + ); + + const options = await provider.listMachineOptions?.({ ...PROFILE, class: "class-00" }); + + expect(options).toHaveLength(32); + expect(options?.[0]).toEqual({ id: "class-00", label: "Class-00", default: true }); + expect(options?.at(-1)).toEqual({ + id: "class-31", + label: "Class-31", + cpu: 32, + memoryGb: 64, + }); + expect(options?.some((option) => option.id === invalidClass)).toBe(false); + }); + it("keeps machine-shape catalogs separate per resolved binary", async () => { const calls: { binary: string; argv: string[] }[] = []; const provider = providerWithRunner(async (argv) => { @@ -243,6 +276,11 @@ describe("Crabbox worker provider", () => { result: () => Promise.resolve(commandResult({ stdout: "not-json" })), warns: true, }, + { + name: "returns an empty catalog", + result: () => Promise.resolve(commandResult({ stdout: "[]" })), + warns: false, + }, { name: "omits classes", result: () => @@ -264,21 +302,6 @@ describe("Crabbox worker provider", () => { ), warns: false, }, - { - name: "reports invalid shape values", - result: () => - Promise.resolve( - commandResult({ - stdout: JSON.stringify([ - { - provider: "aws", - classes: [{ class: "standard", vcpu: 0, memoryGb: 63.5 }], - }, - ]), - }), - ), - warns: false, - }, ])("keeps complete label-only options when providers $name", async ({ result, warns }) => { const warn = vi.fn(); const provider = providerWithRunner(result, warn);