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
This commit is contained in:
Peter Steinberger
2026-08-20 01:31:27 -07:00
committed by GitHub
parent 68ae235318
commit 1d2986b06f
25 changed files with 327 additions and 64 deletions
+2
View File
@@ -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:
@@ -270,6 +270,7 @@ describe("ModelsListResultSchema", () => {
id: "codex",
fallback: "openclaw",
cloudPlacementSupported: true,
cloudPlacementExecutionMode: "remote-exec",
devicePlacementSupported: false,
source: "model",
},
@@ -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"),
@@ -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: [],
@@ -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<typeof RuntimeTargetIssueSchema>;
export type WorkerSlotSummary = Static<typeof WorkerSlotSummarySchema>;
export type WorkerEnvironmentMetadata = Static<typeof WorkerEnvironmentMetadataSchema>;
export type WorkerMachineOption = Static<typeof WorkerMachineOptionSchema>;
export type WorkerExecutionMode = Static<typeof WorkerExecutionModeSchema>;
export type EnvironmentSummary = Static<typeof EnvironmentSummarySchema>;
export type EnvironmentsCreateParams = Static<typeof EnvironmentsCreateParamsSchema>;
export type EnvironmentsCreateResult = Static<typeof EnvironmentsCreateResultSchema>;
@@ -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<TestWorkerService> = {}) {
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<Record<string, unknown>> }).profiles.find(
(profile) => profile.id === "zeta",
),
).not.toHaveProperty("executionMode");
});
it.each([
+7 -4
View File
@@ -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;
}
}),
);
@@ -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" },
}),
],
});
@@ -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",
},
@@ -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",
},
@@ -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,
};
+2
View File
@@ -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"
@@ -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();
+2
View File
@@ -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.",
+26 -33
View File
@@ -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<HTMLButtonElement>('[data-value="cloud:aws"]');
const enabled = container.querySelector<HTMLButtonElement>('[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");
});
});
+9 -8
View File
@@ -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. */
+27 -4
View File
@@ -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",
},
]);
});
});
+10 -1
View File
@@ -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));
}
@@ -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 }),
);
});
});
+18 -2
View File
@@ -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 = "";
@@ -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) {
@@ -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" },
+17
View File
@@ -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<ApplicationContext["gateway"]["snapshot"]["client"]>;
@@ -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;
@@ -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(),
+2
View File
@@ -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 &&