From 1d2986b06fbe5d85bab5fda799abb880d28f3827 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 20 Aug 2026 01:31:27 -0700 Subject: [PATCH] fix: prevent incompatible cloud runtime dispatches (#126585) * fix(ui): gate cloud profiles by runtime mode * test(gateway): expect cloud placement mode * test(gateway): keep route suite within lint budget --- docs/gateway/cloud-workers.md | 2 + .../src/schema/agents-models-skills.test.ts | 1 + .../src/schema/agents-models-skills.ts | 2 + .../src/schema/environments.test.ts | 9 +++ .../src/schema/environments.ts | 8 +++ .../server-methods/environments.test.ts | 19 +++++- src/gateway/server-methods/environments.ts | 11 ++-- .../models-list-result.openai-routes.test.ts | 4 +- src/gateway/server-methods/models.test.ts | 3 + .../server.models-voicewake-misc.test.ts | 5 ++ .../placement-session-runtime.ts | 2 + src/shared/session-types.ts | 2 + .../new-session-page.places-live.e2e.test.ts | 45 ++++++++++++++ ui/src/i18n/locales/en.ts | 2 + ui/src/pages/new-session/cloud-target.test.ts | 59 ++++++++----------- ui/src/pages/new-session/cloud-target.ts | 17 +++--- ui/src/pages/new-session/discovery.test.ts | 31 ++++++++-- ui/src/pages/new-session/discovery.ts | 11 +++- .../new-session/draft-place-state.test.ts | 50 ++++++++++++++++ ui/src/pages/new-session/draft-place-state.ts | 20 ++++++- .../new-session/draft-submission-flow.ts | 13 ++-- .../pages/new-session/model-control.test.ts | 54 +++++++++++++++++ ui/src/pages/new-session/model-control.ts | 17 ++++++ ui/src/pages/new-session/new-session-page.ts | 2 + ui/src/pages/new-session/where-chip.ts | 2 + 25 files changed, 327 insertions(+), 64 deletions(-) diff --git a/docs/gateway/cloud-workers.md b/docs/gateway/cloud-workers.md index 63e5dc32b26e..bdd8feb9c787 100644 --- a/docs/gateway/cloud-workers.md +++ b/docs/gateway/cloud-workers.md @@ -182,6 +182,8 @@ While a placement is active, OpenClaw automatically samples available space on t - **OpenClaw** uses `worker-turn` placement. The restricted `openclaw worker` process runs each turn on the leased node and proxies inference through the Gateway. - **Codex** uses `remote-exec` placement only when the selected provider advertises an SSH-backed execution carrier. The bundled Crabbox node provider does not, so Codex dispatch to Crabbox fails before allocation. +The Control UI disables cloud destinations whose advertised mode does not match the selected runtime. + Other runtimes remain unavailable unless their harness explicitly declares a cloud placement mode. Cloud targets are not offered for external CLI session catalogs. Remote-exec fails closed if the selected provider or placement sandbox is unavailable; it never falls back to running the operation on the Gateway host. The equivalent RPC flow is: diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.test.ts b/packages/gateway-protocol/src/schema/agents-models-skills.test.ts index 3726109921db..70a5515c88a2 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.test.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.test.ts @@ -270,6 +270,7 @@ describe("ModelsListResultSchema", () => { id: "codex", fallback: "openclaw", cloudPlacementSupported: true, + cloudPlacementExecutionMode: "remote-exec", devicePlacementSupported: false, source: "model", }, diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.ts b/packages/gateway-protocol/src/schema/agents-models-skills.ts index f43cdec45c28..1ce9a589951f 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.ts @@ -2,6 +2,7 @@ import type { Static } from "typebox"; import { Type } from "typebox"; import { closedObject } from "./closed-object.js"; +import { WorkerExecutionModeSchema } from "./environments.js"; import { NonEmptyString } from "./primitives.js"; import { GitHubSetupHandleSchema } from "./secrets.js"; @@ -19,6 +20,7 @@ const GatewayAgentRuntimeSchema = closedObject({ id: NonEmptyString, fallback: Type.Optional(Type.Union([Type.Literal("openclaw"), Type.Literal("none")])), cloudPlacementSupported: Type.Optional(Type.Boolean()), + cloudPlacementExecutionMode: Type.Optional(WorkerExecutionModeSchema), devicePlacementSupported: Type.Optional(Type.Boolean()), source: Type.Union([ Type.Literal("env"), diff --git a/packages/gateway-protocol/src/schema/environments.test.ts b/packages/gateway-protocol/src/schema/environments.test.ts index 7364346c71e0..906b434a9524 100644 --- a/packages/gateway-protocol/src/schema/environments.test.ts +++ b/packages/gateway-protocol/src/schema/environments.test.ts @@ -229,6 +229,7 @@ describe("worker environment protocol schemas", () => { id: "aws", providerId: "crabbox", trust: "disposable", + executionMode: "remote-exec", machines: [ { id: "standard", @@ -239,6 +240,8 @@ describe("worker environment protocol schemas", () => { }, ], }, + { id: "worker", providerId: "static-ssh", executionMode: "worker-turn" }, + { id: "legacy", providerId: "static-ssh" }, ], }), ).toBe(true); @@ -254,6 +257,12 @@ describe("worker environment protocol schemas", () => { profiles: [{ id: "aws", providerId: "crabbox", trust: "temporary" }], }), ).toBe(false); + expect( + Value.Check(EnvironmentsListResultSchema, { + environments: [], + profiles: [{ id: "aws", providerId: "crabbox", executionMode: "sandbox" }], + }), + ).toBe(false); expect( Value.Check(EnvironmentsListResultSchema, { environments: [], diff --git a/packages/gateway-protocol/src/schema/environments.ts b/packages/gateway-protocol/src/schema/environments.ts index 68ac0a070798..1c6f9279fbd5 100644 --- a/packages/gateway-protocol/src/schema/environments.ts +++ b/packages/gateway-protocol/src/schema/environments.ts @@ -128,11 +128,18 @@ export const WorkerMachineOptionsSchema = Type.Array(WorkerMachineOptionSchema, maxItems: 32, }); +/** Placement execution modes shared by runtime requirements and worker providers. */ +export const WorkerExecutionModeSchema = Type.Union([ + Type.Literal("worker-turn"), + Type.Literal("remote-exec"), +]); + /** Configured worker target exposed without provider settings or credentials. */ const WorkerEnvironmentProfileSummarySchema = closedObject({ id: NonEmptyString, providerId: NonEmptyString, trust: Type.Optional(EnvironmentTrustSchema), + executionMode: Type.Optional(WorkerExecutionModeSchema), machines: Type.Optional(WorkerMachineOptionsSchema), }); @@ -199,6 +206,7 @@ export type RuntimeTargetIssue = Static; export type WorkerSlotSummary = Static; export type WorkerEnvironmentMetadata = Static; export type WorkerMachineOption = Static; +export type WorkerExecutionMode = Static; export type EnvironmentSummary = Static; export type EnvironmentsCreateParams = Static; export type EnvironmentsCreateResult = Static; diff --git a/src/gateway/server-methods/environments.test.ts b/src/gateway/server-methods/environments.test.ts index 3c1e232dcc6a..22830481ee59 100644 --- a/src/gateway/server-methods/environments.test.ts +++ b/src/gateway/server-methods/environments.test.ts @@ -44,6 +44,7 @@ type TestWorkerRecord = WorkerEnvironmentRecord & type TestWorkerService = { list: () => TestWorkerRecord[]; get: (environmentId: string) => TestWorkerRecord | undefined; + supportsExecutionMode: (profileId: string, mode: "worker-turn" | "remote-exec") => boolean; listMachineOptions: ( profileId: string, ) => Promise< @@ -147,6 +148,7 @@ function workerService(overrides: Partial = {}) { return { list: vi.fn(() => []), get: vi.fn(() => undefined), + supportsExecutionMode: vi.fn(() => false), listMachineOptions: vi.fn(async () => undefined), create: vi.fn(async () => workerRecord()), destroy: vi.fn(async () => workerRecord({ state: "destroyed" })), @@ -494,7 +496,7 @@ describe("environment gateway methods", () => { expect(worker?.worker).not.toHaveProperty("keyRef"); }); - it("adds provider machine options to configured profile summaries", async () => { + it("adds known provider capabilities to configured profile summaries", async () => { const listMachineOptions = vi.fn(async (profileId: string) => profileId === "aws" ? [ @@ -511,7 +513,14 @@ describe("environment gateway methods", () => { const [ok, payload] = await callEnvironmentMethod( "environments.list", {}, - { service: workerService({ listMachineOptions }) }, + { + service: workerService({ + listMachineOptions, + supportsExecutionMode: vi.fn( + (profileId, mode) => profileId === "aws" && mode === "remote-exec", + ), + }), + }, ); expect(ok).toBe(true); @@ -520,6 +529,7 @@ describe("environment gateway methods", () => { { id: "aws", providerId: "crabbox", + executionMode: "remote-exec", machines: [ { id: "standard", @@ -534,6 +544,11 @@ describe("environment gateway methods", () => { ], }); expect(listMachineOptions.mock.calls).toEqual([["aws"], ["zeta"]]); + expect( + (payload as { profiles: Array> }).profiles.find( + (profile) => profile.id === "zeta", + ), + ).not.toHaveProperty("executionMode"); }); it.each([ diff --git a/src/gateway/server-methods/environments.ts b/src/gateway/server-methods/environments.ts index c8cdd88aa098..56cab3ae553b 100644 --- a/src/gateway/server-methods/environments.ts +++ b/src/gateway/server-methods/environments.ts @@ -229,17 +229,20 @@ async function listWorkerProfilesWithMachines(context: GatewayRequestContext) { const summaries = listWorkerProfiles(context); return await Promise.all( summaries.map(async (summary) => { + const executionMode = (["worker-turn", "remote-exec"] as const).find( + (mode) => + context.workerEnvironmentService?.supportsExecutionMode?.(summary.id, mode) === true, + ); + const resolvedSummary = Object.assign(summary, executionMode ? { executionMode } : {}); try { const options = await context.workerEnvironmentService?.listMachineOptions?.(summary.id); const machines = options ?? []; - return machines.length > 0 - ? { id: summary.id, providerId: summary.providerId, machines } - : summary; + return machines.length > 0 ? Object.assign(resolvedSummary, { machines }) : resolvedSummary; } catch (error) { context.logGateway.warn( `worker machine catalog unavailable (${summary.id}): ${formatForLog(error)}`, ); - return summary; + return resolvedSummary; } }), ); diff --git a/src/gateway/server-methods/models-list-result.openai-routes.test.ts b/src/gateway/server-methods/models-list-result.openai-routes.test.ts index 69ec4695d597..14e5ff585bb8 100644 --- a/src/gateway/server-methods/models-list-result.openai-routes.test.ts +++ b/src/gateway/server-methods/models-list-result.openai-routes.test.ts @@ -23,11 +23,11 @@ const IMPLICIT_CODEX_RUNTIME = { const IMPLICIT_OPENCLAW_RUNTIME = { id: "openclaw", cloudPlacementSupported: true, + cloudPlacementExecutionMode: "worker-turn", devicePlacementSupported: true, source: "implicit", } as const; const MODEL_CODEX_RUNTIME = { ...IMPLICIT_CODEX_RUNTIME, source: "model" } as const; -const MODEL_OPENCLAW_RUNTIME = { ...IMPLICIT_OPENCLAW_RUNTIME, source: "model" } as const; function preparedOwnerFacts(config: OpenClawConfig) { return { @@ -472,7 +472,7 @@ describe("models.list OpenAI routes", () => { expect.objectContaining({ id: "gpt-worker", provider: "openai", - agentRuntime: MODEL_OPENCLAW_RUNTIME, + agentRuntime: { ...IMPLICIT_OPENCLAW_RUNTIME, source: "model" }, }), ], }); diff --git a/src/gateway/server-methods/models.test.ts b/src/gateway/server-methods/models.test.ts index 636d6c4e95d1..f72e1c902ea9 100644 --- a/src/gateway/server-methods/models.test.ts +++ b/src/gateway/server-methods/models.test.ts @@ -697,6 +697,7 @@ describe("models.list", () => { agentRuntime: { id: "openclaw", cloudPlacementSupported: true, + cloudPlacementExecutionMode: "worker-turn", devicePlacementSupported: true, source: "implicit", }, @@ -757,6 +758,7 @@ describe("models.list", () => { agentRuntime: { id: "openclaw", cloudPlacementSupported: true, + cloudPlacementExecutionMode: "worker-turn", devicePlacementSupported: true, source: "implicit", }, @@ -808,6 +810,7 @@ describe("models.list", () => { agentRuntime: { id: "openclaw", cloudPlacementSupported: true, + cloudPlacementExecutionMode: "worker-turn", devicePlacementSupported: true, source: "implicit", }, diff --git a/src/gateway/server.models-voicewake-misc.test.ts b/src/gateway/server.models-voicewake-misc.test.ts index 558b04998202..60c309682de7 100644 --- a/src/gateway/server.models-voicewake-misc.test.ts +++ b/src/gateway/server.models-voicewake-misc.test.ts @@ -149,6 +149,7 @@ const expectedSortedCatalog = (gptTestZTags?: string[]): ModelCatalogRpcEntry[] agentRuntime: { id: "openclaw", cloudPlacementSupported: true, + cloudPlacementExecutionMode: "worker-turn", devicePlacementSupported: true, source: "implicit", }, @@ -162,6 +163,7 @@ const expectedSortedCatalog = (gptTestZTags?: string[]): ModelCatalogRpcEntry[] agentRuntime: { id: "openclaw", cloudPlacementSupported: true, + cloudPlacementExecutionMode: "worker-turn", devicePlacementSupported: true, source: "implicit", }, @@ -794,6 +796,7 @@ describe("gateway server models + voicewake", () => { agentRuntime: { id: "openclaw", cloudPlacementSupported: true, + cloudPlacementExecutionMode: "worker-turn", devicePlacementSupported: true, source: "implicit", }, @@ -851,6 +854,7 @@ describe("gateway server models + voicewake", () => { agentRuntime: { id: "openclaw", cloudPlacementSupported: true, + cloudPlacementExecutionMode: "worker-turn", devicePlacementSupported: true, source: "implicit", }, @@ -875,6 +879,7 @@ describe("gateway server models + voicewake", () => { agentRuntime: { id: "openclaw", cloudPlacementSupported: true, + cloudPlacementExecutionMode: "worker-turn", devicePlacementSupported: true, source: "implicit", }, diff --git a/src/gateway/worker-environments/placement-session-runtime.ts b/src/gateway/worker-environments/placement-session-runtime.ts index 50dc877d6a0d..4c281b55b768 100644 --- a/src/gateway/worker-environments/placement-session-runtime.ts +++ b/src/gateway/worker-environments/placement-session-runtime.ts @@ -48,6 +48,7 @@ export function projectWorkerPlacementAgentRuntime( runtime: GatewayAgentRuntime, ): GatewayAgentRuntime & { cloudPlacementSupported: boolean; + cloudPlacementExecutionMode?: WorkerPlacementExecutionMode; devicePlacementSupported: boolean; } { const { source, ...identity } = runtime; @@ -55,6 +56,7 @@ export function projectWorkerPlacementAgentRuntime( return { ...identity, cloudPlacementSupported: executionMode !== undefined, + ...(executionMode ? { cloudPlacementExecutionMode: executionMode } : {}), devicePlacementSupported: executionMode === "worker-turn", source, }; diff --git a/src/shared/session-types.ts b/src/shared/session-types.ts index cd08fe84b7e0..fcee9becc6ca 100644 --- a/src/shared/session-types.ts +++ b/src/shared/session-types.ts @@ -1,6 +1,7 @@ import type { SessionCreatedActor, SessionsAssignOwnerParams, + WorkerExecutionMode, } from "../../packages/gateway-protocol/src/index.js"; /** Agent identity fields returned by gateway session listing APIs. */ @@ -23,6 +24,7 @@ export type GatewayAgentRuntime = { id: string; fallback?: "openclaw" | "none"; cloudPlacementSupported?: boolean; + cloudPlacementExecutionMode?: WorkerExecutionMode; devicePlacementSupported?: boolean; source: | "env" diff --git a/ui/src/e2e/new-session-page.places-live.e2e.test.ts b/ui/src/e2e/new-session-page.places-live.e2e.test.ts index 2b086c93f76d..be279121d9e1 100644 --- a/ui/src/e2e/new-session-page.places-live.e2e.test.ts +++ b/ui/src/e2e/new-session-page.places-live.e2e.test.ts @@ -69,6 +69,51 @@ suite.define(() => { } }); + it("disables cloud profiles whose execution mode does not match the selected runtime", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + agentModel: "openai/gpt-5.6-luna", + models: [ + { + id: "gpt-5.6-luna", + name: "GPT-5.6 Luna", + provider: "openai", + agentRuntime: { + id: "codex", + cloudPlacementSupported: true, + cloudPlacementExecutionMode: "remote-exec", + source: "model", + }, + }, + ], + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "environments.list": { + environments: [], + profiles: [{ id: "aws", providerId: "crabbox", executionMode: "worker-turn" }], + }, + "worktrees.branches": { branches: [], repositoryStatus: "git" }, + }, + }); + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("environments.list"); + await gateway.waitForRequest("chat.metadata"); + await page.locator("#new-session-where-trigger").click(); + + const profile = page.locator('[data-value="cloud:aws"]'); + await profile.waitFor(); + await expect.poll(() => profile.isDisabled()).toBe(true); + expect(await profile.getAttribute("title")).toBe( + "The codex runtime cannot use this cloud worker. Choose a compatible cloud worker or run locally.", + ); + } finally { + await context.close(); + } + }); + it("refreshes authoritative device capacity from Gateway topology events", async () => { const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); const page = await context.newPage(); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index e08cd92f3d16..937892248fba 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -815,6 +815,8 @@ export const en: TranslationMap = { cloudWorkerProvider: "Cloud worker provider: {provider}", cloudRequiresWorktree: "Cloud needs a Git checkout", cloudRuntimeUnsupported: "The {runtime} runtime does not support cloud workers.", + cloudProfileRuntimeUnsupported: + "The {runtime} runtime cannot use this cloud worker. Choose a compatible cloud worker or run locally.", deviceRuntimeUnsupported: "Needs the embedded runtime", cloudRecoveryUnavailable: "Cloud workers are unavailable because this connection does not support task recovery. Reconnect or update the Gateway.", diff --git a/ui/src/pages/new-session/cloud-target.test.ts b/ui/src/pages/new-session/cloud-target.test.ts index 622e28a12857..f36462530669 100644 --- a/ui/src/pages/new-session/cloud-target.test.ts +++ b/ui/src/pages/new-session/cloud-target.test.ts @@ -3,7 +3,6 @@ import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; import { renderCloudMachineMenuItems, renderCloudProfileMenuItems } from "./cloud-target.ts"; -import { DraftSubmissionFlow } from "./draft-submission-flow.ts"; describe("cloud target menu", () => { it.each([ @@ -42,38 +41,6 @@ describe("cloud target menu", () => { expect(container.querySelector(".session-menu__sub")?.textContent).toBe(expected); }); - it.each([ - { - name: "keeps an advertised supported runtime enabled", - runtime: { id: "codex", cloudPlacementSupported: true, source: "model" as const }, - expected: undefined, - }, - { - name: "leaves an unadvertised runtime to the Gateway dispatch gate", - runtime: { id: "codex", source: "model" as const }, - expected: undefined, - }, - { - name: "explains an advertised unsupported runtime", - runtime: { id: "acpx", cloudPlacementSupported: false, source: "model" as const }, - expected: "The acpx runtime does not support cloud workers.", - }, - ])("$name", ({ runtime, expected }) => { - const flow = new DraftSubmissionFlow( - {} as never, - { - modelControl: { resolveAgentRuntime: () => runtime }, - repository: { kind: "git", repoRoot: "/repo", branches: [] }, - selectedAgent: () => undefined, - worktreeAvailable: () => true, - } as never, - () => ({ context: undefined, data: undefined, isConnected: true }), - { requestUpdate: vi.fn(), closeTransientUi: vi.fn() }, - ); - - expect(flow.cloudDisabledReason()).toBe(expected); - }); - it("disables cloud profiles with the runtime preflight reason", () => { const container = document.createElement("div"); render( @@ -92,4 +59,30 @@ describe("cloud target menu", () => { expect(button?.disabled).toBe(true); expect(button?.title).toBe("The acpx runtime does not support cloud workers."); }); + + it("disables only the cloud profile with a profile-specific reason", () => { + const reason = + "The codex runtime cannot use this cloud worker. Choose a compatible cloud worker or run locally."; + const container = document.createElement("div"); + render( + renderCloudProfileMenuItems({ + profiles: [ + { id: "aws", providerId: "crabbox" }, + { id: "ssh", providerId: "static-ssh" }, + ], + selectedId: "", + submitting: false, + profileDisabledReason: (profile) => (profile.id === "aws" ? reason : undefined), + onSelect: vi.fn(), + }), + container, + ); + + const disabled = container.querySelector('[data-value="cloud:aws"]'); + const enabled = container.querySelector('[data-value="cloud:ssh"]'); + expect(disabled?.disabled).toBe(true); + expect(disabled?.title).toBe(reason); + expect(enabled?.disabled).toBe(false); + expect(enabled?.title).toBe("Cloud worker provider: static-ssh"); + }); }); diff --git a/ui/src/pages/new-session/cloud-target.ts b/ui/src/pages/new-session/cloud-target.ts index 339a40fa303d..707145f594ec 100644 --- a/ui/src/pages/new-session/cloud-target.ts +++ b/ui/src/pages/new-session/cloud-target.ts @@ -84,10 +84,12 @@ export function renderCloudProfileMenuItems(params: { icon?: unknown; disabled?: boolean; disabledReason?: string; + profileDisabledReason?: (profile: DraftCloudProfile) => string | undefined; onSelect: (profileId: string) => void; }) { - return params.profiles.map((profile) => - renderSessionMenuItem( + return params.profiles.map((profile) => { + const profileDisabledReason = params.profileDisabledReason?.(profile); + return renderSessionMenuItem( { value: `cloud:${profile.id}`, label: t("newSession.cloudWorker", { profile: profile.id }), @@ -99,16 +101,15 @@ export function renderCloudProfileMenuItems(params: { ? [t("newSession.environmentPersistent")] : undefined, checked: params.selectedId === profile.id, - disabled: params.disabled, + disabled: params.disabled || Boolean(profileDisabledReason), title: - params.disabled && params.disabledReason - ? params.disabledReason - : t("newSession.cloudWorkerProvider", { provider: profile.providerId }), + (params.disabled ? params.disabledReason : profileDisabledReason) ?? + t("newSession.cloudWorkerProvider", { provider: profile.providerId }), onSelect: () => params.onSelect(profile.id), }, params.submitting, - ), - ); + ); + }); } /** Machine shape as a picker sub-line; providers may report neither, one, or both numbers. */ diff --git a/ui/src/pages/new-session/discovery.test.ts b/ui/src/pages/new-session/discovery.test.ts index 8a8f147a2098..f1e31ae926c0 100644 --- a/ui/src/pages/new-session/discovery.test.ts +++ b/ui/src/pages/new-session/discovery.test.ts @@ -11,12 +11,14 @@ describe("readDraftCloudProfiles", () => { id: " zeta ", providerId: " static-ssh ", trust: "disposable", + executionMode: "worker-turn", settings: { token: "hidden" }, }, { id: "aws", providerId: "crabbox", trust: "persistent", + executionMode: "remote-exec", machines: [ { id: "standard", @@ -31,7 +33,12 @@ describe("readDraftCloudProfiles", () => { ], }, { id: "legacy", providerId: "static-ssh" }, - { id: "invalid-trust", providerId: "crabbox", trust: "temporary" }, + { + id: "invalid-trust", + providerId: "crabbox", + trust: "temporary", + executionMode: "sandbox", + }, { id: "", providerId: "crabbox" }, { id: "missing-provider" }, ]), @@ -40,6 +47,7 @@ describe("readDraftCloudProfiles", () => { id: "aws", providerId: "crabbox", trust: "persistent", + executionMode: "remote-exec", machines: [ { id: "standard", @@ -51,9 +59,24 @@ describe("readDraftCloudProfiles", () => { { id: "fast", label: "Fast" }, ], }, - { id: "invalid-trust", providerId: "crabbox", trust: undefined }, - { id: "legacy", providerId: "static-ssh", trust: undefined }, - { id: "zeta", providerId: "static-ssh", trust: "disposable" }, + { + id: "invalid-trust", + providerId: "crabbox", + trust: undefined, + executionMode: undefined, + }, + { + id: "legacy", + providerId: "static-ssh", + trust: undefined, + executionMode: undefined, + }, + { + id: "zeta", + providerId: "static-ssh", + trust: "disposable", + executionMode: "worker-turn", + }, ]); }); }); diff --git a/ui/src/pages/new-session/discovery.ts b/ui/src/pages/new-session/discovery.ts index b611ddcf04fb..8778e2e45d84 100644 --- a/ui/src/pages/new-session/discovery.ts +++ b/ui/src/pages/new-session/discovery.ts @@ -4,6 +4,7 @@ import { normalizeArrayBackedTrimmedStringList } from "@openclaw/normalization-c import type { EnvironmentStatus, RuntimeTargetIssue, + WorkerExecutionMode, WorkerSlotSummary, } from "../../../../packages/gateway-protocol/src/schema/environments.ts"; @@ -25,6 +26,7 @@ export type DraftCloudProfile = { id: string; providerId: string; trust?: "persistent" | "disposable"; + executionMode?: WorkerExecutionMode; machines?: DraftMachineOption[]; }; @@ -92,6 +94,7 @@ export function readDraftCloudProfiles(value: unknown): DraftCloudProfile[] { id?: unknown; providerId?: unknown; trust?: unknown; + executionMode?: unknown; machines?: unknown; }; const id = normalizeOptionalString(profile.id); @@ -103,8 +106,14 @@ export function readDraftCloudProfiles(value: unknown): DraftCloudProfile[] { profile.trust === "persistent" || profile.trust === "disposable" ? profile.trust : undefined; + const executionMode: WorkerExecutionMode | undefined = + profile.executionMode === "worker-turn" || profile.executionMode === "remote-exec" + ? profile.executionMode + : undefined; const machines = readDraftMachineOptions(profile.machines); - return [{ id, providerId, trust, ...(machines.length > 0 ? { machines } : {}) }]; + return [ + { id, providerId, trust, executionMode, ...(machines.length > 0 ? { machines } : {}) }, + ]; }) .toSorted((left, right) => left.id.localeCompare(right.id)); } diff --git a/ui/src/pages/new-session/draft-place-state.test.ts b/ui/src/pages/new-session/draft-place-state.test.ts index 624701e1b57f..4e0be57ef586 100644 --- a/ui/src/pages/new-session/draft-place-state.test.ts +++ b/ui/src/pages/new-session/draft-place-state.test.ts @@ -99,4 +99,54 @@ describe("DraftPlaceState cloud machine selection", () => { state.applyPendingPlacement({ agentId: "main", profileId: "aws" }); expect(state.machineClass).toBe(""); }); + + it("clears a selected cloud profile when the runtime switches to an incompatible mode", () => { + const persistPreference = vi.fn(); + const cloudProfiles: DraftCloudProfile[] = [ + { id: "aws", providerId: "crabbox", executionMode: "worker-turn" }, + ]; + const state = new DraftPlaceState( + { cloudProfiles, persistPreference } as unknown as DraftGatewayState, + { + clearProjectSelection: vi.fn(), + close: vi.fn(), + projectId: "", + remoteProject: null, + selectedProject: vi.fn(() => undefined), + } as unknown as DraftPlaceBrowser, + () => ({ + context: undefined, + data: undefined, + submitting: false, + pendingPlacementSessionKey: "", + }), + { requestUpdate: vi.fn(), onError: vi.fn(), onClearError: vi.fn() }, + ); + const resolveRuntime = vi.spyOn(state.modelControl, "resolveAgentRuntime"); + resolveRuntime.mockReturnValue({ + id: "openclaw", + cloudPlacementSupported: true, + cloudPlacementExecutionMode: "worker-turn", + source: "model", + }); + state.applyPendingPlacement({ agentId: "main", profileId: "aws" }); + state.restorePreferenceSelections(); + expect(state.cloudProfileId).toBe("aws"); + + resolveRuntime.mockReturnValue({ + id: "codex", + cloudPlacementSupported: true, + cloudPlacementExecutionMode: "remote-exec", + source: "model", + }); + state.restorePreferenceSelections(); + + expect(state.cloudProfileId).toBe(""); + expect(state.worktree).toBe(false); + expect(persistPreference).toHaveBeenLastCalledWith( + "main", + "", + expect.objectContaining({ where: { kind: "local" }, worktree: false }), + ); + }); }); diff --git a/ui/src/pages/new-session/draft-place-state.ts b/ui/src/pages/new-session/draft-place-state.ts index 6d6df556f41f..d1f18f047e70 100644 --- a/ui/src/pages/new-session/draft-place-state.ts +++ b/ui/src/pages/new-session/draft-place-state.ts @@ -532,12 +532,14 @@ export class DraftPlaceState { selectCloudProfile(profileId: string) { const snapshot = this.read(); + const profile = this.gateway.cloudProfiles.find((candidate) => candidate.id === profileId); if ( snapshot.submitting || snapshot.pendingPlacementSessionKey || !this.isAdmin() || !this.worktreeAvailable() || - !this.gateway.cloudProfiles.some((profile) => profile.id === profileId) + !profile || + Boolean(this.modelControl.cloudRuntimeUnsupportedReason(profile)) ) { return; } @@ -572,6 +574,17 @@ export class DraftPlaceState { } restorePreferenceSelections() { + const selectedCloudProfile = this.gateway.cloudProfiles.find( + (profile) => profile.id === this.cloudProfileIdValue, + ); + if ( + selectedCloudProfile && + this.modelControl.cloudRuntimeUnsupportedReason(selectedCloudProfile) && + !this.read().pendingPlacementSessionKey + ) { + this.selectDevice(""); + return; + } let changed = false; const preferredWhere = this.whereSelectedByUser ? null : this.preferredWhereRestore; let preferredProject = this.projectSelectedByUser ? "" : this.preferredProjectRestore; @@ -598,9 +611,12 @@ export class DraftPlaceState { this.preferredWhereRestore = null; changed = true; } else if (preferredWhere?.kind === "cloud" && this.gateway.cloudProfilesReady) { - const profileAvailable = this.gateway.cloudProfiles.some( + const preferredProfile = this.gateway.cloudProfiles.find( (profile) => profile.id === preferredWhere.id, ); + const profileAvailable = Boolean( + preferredProfile && !this.modelControl.cloudRuntimeUnsupportedReason(preferredProfile), + ); const projectReady = !preferredProject || this.browser.projectId === preferredProject; if (this.isAdmin() && profileAvailable && projectReady && this.worktreeAvailable()) { this.deviceIdValue = ""; diff --git a/ui/src/pages/new-session/draft-submission-flow.ts b/ui/src/pages/new-session/draft-submission-flow.ts index af83e741dcb1..4488358b3641 100644 --- a/ui/src/pages/new-session/draft-submission-flow.ts +++ b/ui/src/pages/new-session/draft-submission-flow.ts @@ -333,7 +333,7 @@ export class DraftSubmissionFlow { } cloudDisabledReason(): string | undefined { - const runtimeReason = this.cloudRuntimeUnsupportedReason(); + const runtimeReason = this.place.modelControl.cloudRuntimeUnsupportedReason(); if (runtimeReason) { return runtimeReason; } @@ -724,13 +724,10 @@ export class DraftSubmissionFlow { private placement = () => resolveDraftSessionPlacement(this.pendingPlacement, this.place); private cloudRuntimeUnsupportedReason(): string | undefined { - const runtime = this.place.modelControl.resolveAgentRuntime({ - agent: this.place.selectedAgent(), - context: this.read().context, - }); - return runtime?.cloudPlacementSupported === false - ? t("newSession.cloudRuntimeUnsupported", { runtime: runtime.id }) - : undefined; + const profile = this.gateway.cloudProfiles.find( + (candidate) => candidate.id === this.place.cloudProfileId, + ); + return this.place.modelControl.cloudRuntimeUnsupportedReason(profile); } private applyRecoveryDraft(recovery: SessionPlacementRecovery) { diff --git a/ui/src/pages/new-session/model-control.test.ts b/ui/src/pages/new-session/model-control.test.ts index 8b8d365b6845..e970b311a95c 100644 --- a/ui/src/pages/new-session/model-control.test.ts +++ b/ui/src/pages/new-session/model-control.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { GatewayAgentRow, ModelCatalogEntry } from "../../api/types.ts"; import type { ApplicationContext } from "../../app/context.ts"; +import type { DraftCloudProfile } from "./discovery.ts"; import { contextWith, deferred, renderControl } from "./model-control.test-support.ts"; import { NewSessionModelControl } from "./model-control.ts"; @@ -9,6 +10,59 @@ afterEach(() => { }); describe("new-session model runtime", () => { + it.each([ + { + name: "rejects a remote-exec runtime on a worker-turn profile", + runtime: { + id: "codex", + cloudPlacementSupported: true, + cloudPlacementExecutionMode: "remote-exec" as const, + source: "model" as const, + }, + executionMode: "worker-turn" as const, + expected: + "The codex runtime cannot use this cloud worker. Choose a compatible cloud worker or run locally.", + }, + { + name: "accepts a worker-turn runtime on a worker-turn profile", + runtime: { + id: "openclaw", + cloudPlacementSupported: true, + cloudPlacementExecutionMode: "worker-turn" as const, + source: "model" as const, + }, + executionMode: "worker-turn" as const, + expected: undefined, + }, + { + name: "preserves an unknown provider mode", + runtime: { + id: "codex", + cloudPlacementSupported: true, + cloudPlacementExecutionMode: "remote-exec" as const, + source: "model" as const, + }, + executionMode: undefined, + expected: undefined, + }, + { + name: "retains the existing whole-runtime rejection", + runtime: { id: "acpx", cloudPlacementSupported: false, source: "model" as const }, + executionMode: "worker-turn" as const, + expected: "The acpx runtime does not support cloud workers.", + }, + ])("$name", ({ runtime, executionMode, expected }) => { + const profile: DraftCloudProfile = { + id: "aws", + providerId: "crabbox", + ...(executionMode ? { executionMode } : {}), + }; + const control = new NewSessionModelControl(() => undefined); + vi.spyOn(control, "resolveAgentRuntime").mockReturnValue(runtime); + + expect(control.cloudRuntimeUnsupportedReason(profile)).toBe(expected); + }); + it("keeps CLI agents hidden and undiscovered while the Labs gate is off", async () => { const { context, request } = contextWith([ { id: "gpt-5.6-luna", name: "GPT-5.6 Luna", provider: "openai" }, diff --git a/ui/src/pages/new-session/model-control.ts b/ui/src/pages/new-session/model-control.ts index 19cebd484e7a..20a5135fc6ba 100644 --- a/ui/src/pages/new-session/model-control.ts +++ b/ui/src/pages/new-session/model-control.ts @@ -20,6 +20,7 @@ import { type ChatModelCatalogState, } from "../chat/components/chat-model-controls.ts"; import type { ChatModelPickerTargetGroup } from "../chat/components/chat-model-picker-options.ts"; +import type { DraftCloudProfile } from "./discovery.ts"; import type { NewSessionPreference } from "./preferences.ts"; type NewSessionMetadataClient = NonNullable; @@ -521,6 +522,22 @@ export class NewSessionModelControl { : undefined; } + cloudRuntimeUnsupportedReason(profile?: DraftCloudProfile): string | undefined { + const runtime = this.resolveAgentRuntime({ + agent: this.pendingAgent, + context: this.pendingContext, + }); + if (runtime?.cloudPlacementSupported === false) { + return t("newSession.cloudRuntimeUnsupported", { runtime: runtime.id }); + } + return runtime && + profile?.executionMode && + runtime.cloudPlacementExecutionMode && + profile.executionMode !== runtime.cloudPlacementExecutionMode + ? t("newSession.cloudProfileRuntimeUnsupported", { runtime: runtime.id }) + : undefined; + } + render(options: { agent?: GatewayAgentRow; agentId: string; diff --git a/ui/src/pages/new-session/new-session-page.ts b/ui/src/pages/new-session/new-session-page.ts index 66070fbe2d80..9057dac1bcb4 100644 --- a/ui/src/pages/new-session/new-session-page.ts +++ b/ui/src/pages/new-session/new-session-page.ts @@ -404,6 +404,8 @@ export class NewSessionPage extends OpenClawLightDomElement { deviceId: this.place.deviceId, worktreeAvailable: this.place.worktreeAvailable(), cloudDisabledReason: this.submission.cloudDisabledReason(), + cloudProfileDisabledReason: (profile) => + this.place.modelControl.cloudRuntimeUnsupportedReason(profile), submitting, pendingPlacement, isAdmin: this.place.isAdmin(), diff --git a/ui/src/pages/new-session/where-chip.ts b/ui/src/pages/new-session/where-chip.ts index 3cad95a839ae..f31640e04d98 100644 --- a/ui/src/pages/new-session/where-chip.ts +++ b/ui/src/pages/new-session/where-chip.ts @@ -82,6 +82,7 @@ export function renderWhereChip(params: { deviceId: string; worktreeAvailable: boolean; cloudDisabledReason?: string; + cloudProfileDisabledReason?: (profile: DraftCloudProfile) => string | undefined; submitting: boolean; pendingPlacement: boolean; popoverOpen: boolean; @@ -182,6 +183,7 @@ export function renderWhereChip(params: { icon: icons.server, disabled: !params.worktreeAvailable || Boolean(params.cloudDisabledReason), disabledReason: params.cloudDisabledReason, + profileDisabledReason: params.cloudProfileDisabledReason, onSelect: params.onSelectCloudProfile, })} ${params.cloudProfileId &&