mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
committed by
GitHub
parent
68ae235318
commit
1d2986b06f
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user