From ced723c2a8ea06ec50724c030d48f244974a5024 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 25 Aug 2026 22:25:55 -0700 Subject: [PATCH] fix(ui): preserve cloud worker provider-owned settings (#129846) --- ui/src/e2e/cloud-workers-settings.e2e.test.ts | 74 +++++++++++++++++- ui/src/i18n/locales/en.ts | 2 +- .../cloud-workers/cloud-worker-config.test.ts | 75 +++++++++++++++++++ .../cloud-workers/cloud-worker-config.ts | 8 ++ .../pages/cloud-workers/cloud-workers-page.ts | 4 + 5 files changed, 160 insertions(+), 3 deletions(-) diff --git a/ui/src/e2e/cloud-workers-settings.e2e.test.ts b/ui/src/e2e/cloud-workers-settings.e2e.test.ts index 2e79b3910cf1..eed59f43c715 100644 --- a/ui/src/e2e/cloud-workers-settings.e2e.test.ts +++ b/ui/src/e2e/cloud-workers-settings.e2e.test.ts @@ -399,7 +399,7 @@ suite.define(() => { } }); - it("deletes a profile and its project defaults only after confirmation", async () => { + it("preserves provider-owned editors and deletes profiles plus their project defaults", async () => { const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); const page = await context.newPage(); const pending = configuredCloudWorkerProfile(); @@ -416,9 +416,28 @@ suite.define(() => { }, }; const gateway = await installMockGateway(page, { - featureMethods: ["config.patch", "environments.list"], + featureMethods: ["config.patch", "config.schema", "environments.list"], methodResponses: { "config.get": configResponse(initialConfig, "cloud-workers-delete-1"), + "config.schema": { + version: "e2e", + generatedAt: "2026-08-25T00:00:00.000Z", + uiHints: {}, + schema: { + type: "object", + properties: { + cloudWorkers: { + type: "object", + properties: { + profiles: { + type: "object", + additionalProperties: { type: "object" }, + }, + }, + }, + }, + }, + }, "config.patch": { ok: true, hash: "cloud-workers-delete-2", @@ -435,6 +454,57 @@ suite.define(() => { const pendingRow = page.locator(".settings-row").filter({ has: page.locator("code", { hasText: /^pending$/ }), }); + await pendingRow.getByRole("button", { name: "Edit" }).click(); + const editor = page.locator(".settings-section", { + has: page.getByRole("heading", { name: "Edit profile", exact: true }), + }); + await expect.poll(() => page.getByLabel("Crabbox backend").inputValue()).toBe("aws"); + + const replacement = { + provider: "static-ssh", + install: "bundle", + settings: { + host: "worker.example.test", + user: "openclaw", + keyRef: { source: "env", provider: "default", id: "QA_PRIVATE_KEY" }, + }, + }; + const replacedConfig = { + cloudWorkers: { + profiles: { pending: replacement, retained }, + projectProfiles: initialConfig.cloudWorkers.projectProfiles, + }, + }; + const configGetCount = (await gateway.getRequests("config.get")).length; + await gateway.setMethodResponse( + "config.get", + configResponse(replacedConfig, "cloud-workers-provider-replaced"), + ); + await gateway.emitGatewayEvent("config.changed", { + path: "/tmp/openclaw.json", + hash: "cloud-workers-provider-replaced", + ts: Date.now(), + }); + await gateway.waitForRequest("config.get", { after: configGetCount }); + await pendingRow.getByText("Provider: static-ssh", { exact: true }).waitFor(); + const saveButton = editor.getByRole("button", { name: "Save" }); + await expect.poll(() => saveButton.isEnabled()).toBe(true); + await saveButton.click(); + await expect + .poll(() => editor.getByRole("alert").textContent()) + .toBe("This profile changed or was removed. Reload the page and try again."); + expect(await gateway.getRequests("config.patch")).toHaveLength(0); + await editor.getByRole("button", { name: "Cancel" }).click(); + + await pendingRow.getByRole("button", { name: "Edit" }).click(); + await expect.poll(() => new URL(page.url()).pathname).toBe("/settings/advanced"); + await expect.poll(() => new URL(page.url()).searchParams.get("section")).toBe("cloudWorkers"); + await gateway.waitForRequest("config.schema"); + await page.locator(".page-title").getByText("Advanced", { exact: true }).waitFor(); + expect(await gateway.getRequests("config.patch")).toHaveLength(0); + await page.goBack(); + await pendingRow.getByText("Provider: static-ssh", { exact: true }).waitFor(); + await pendingRow.getByRole("button", { name: "Delete" }).click(); const confirmation = await waitForConfirmModal(page); await expect.poll(() => confirmation.textContent()).toContain("Delete profile pending?"); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 1339e5f883ff..b8e245e9251d 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -2584,7 +2584,7 @@ export const en: TranslationMap = { intro: "Run agent sessions on ephemeral cloud machines instead of this gateway.", documentation: "Cloud worker documentation", sectionTitle: "Profiles", - sectionDescription: "Each profile defines how Crabbox provisions and retires a worker.", + sectionDescription: "Each profile defines how its provider provisions and retires a worker.", empty: "No cloud worker profiles are configured.", addProfile: "Add profile", editProfile: "Edit profile", diff --git a/ui/src/pages/cloud-workers/cloud-worker-config.test.ts b/ui/src/pages/cloud-workers/cloud-worker-config.test.ts index 1a94594af6d1..1a9dbf52ceb7 100644 --- a/ui/src/pages/cloud-workers/cloud-worker-config.test.ts +++ b/ui/src/pages/cloud-workers/cloud-worker-config.test.ts @@ -18,6 +18,7 @@ const configuredProfile = { ttl: "24h", idleTimeout: "60m", setup: "install-node", + setupEnv: ["OPENCLAW_WORKER_ARTIFACT_TOKEN"], desktop: true, binary: "/opt/crabbox", region: "eu-west-1", @@ -91,6 +92,7 @@ describe("cloud worker settings state", () => { ttl: "8h", idleTimeout: "45m", setup: null, + setupEnv: null, desktop: null, binary: null, region: "eu-west-1", @@ -102,6 +104,79 @@ describe("cloud worker settings state", () => { }); }); + it("preserves forwarded setup environment while its setup command remains configured", () => { + const config = { cloudWorkers: { profiles: { production: configuredProfile } } }; + const draft = createCloudWorkerDraft(readCloudWorkerProfiles(config)[0]); + + expect(buildCloudWorkerUpsertPatch(config, draft, "production")).toMatchObject({ + patch: { + cloudWorkers: { + profiles: { + production: { + settings: { + setup: "install-node", + setupEnv: ["OPENCLAW_WORKER_ARTIFACT_TOKEN"], + }, + }, + }, + }, + }, + }); + }); + + it.each([undefined, []])("keeps empty setup environment unchanged (%j)", (setupEnv) => { + const existingSettings = Object.fromEntries( + Object.entries(configuredProfile.settings).filter( + ([key]) => key !== "setupEnv" || setupEnv !== undefined, + ), + ); + if (setupEnv) { + existingSettings.setupEnv = setupEnv; + } + const profile = { ...configuredProfile, settings: existingSettings }; + const config = { cloudWorkers: { profiles: { production: profile } } }; + const draft = { ...createCloudWorkerDraft(readCloudWorkerProfiles(config)[0]), setup: "" }; + + expect(buildCloudWorkerUpsertPatch(config, draft, "production")).toEqual({ + patch: { + cloudWorkers: { + profiles: { + production: { ...profile, settings: { ...existingSettings, setup: null } }, + }, + }, + }, + }); + }); + + it("rejects an edit after its authoritative profile changes provider", () => { + const config = { + cloudWorkers: { + profiles: { + production: { + provider: "static-ssh", + settings: { host: "worker.example.test", user: "openclaw" }, + }, + }, + }, + }; + const draft = createCloudWorkerDraft({ + id: "production", + providerId: "crabbox", + install: "bundle", + backend: "aws", + machineClass: "standard", + ttl: "8h", + idleTimeout: "45m", + setup: "", + desktop: false, + binary: "", + }); + + expect(buildCloudWorkerUpsertPatch(config, draft, "production")).toEqual({ + error: "profileMissing", + }); + }); + it("builds add and delete payloads against the complete profile record", () => { const config = { cloudWorkers: { profiles: { production: configuredProfile } } }; const draft = { diff --git a/ui/src/pages/cloud-workers/cloud-worker-config.ts b/ui/src/pages/cloud-workers/cloud-worker-config.ts index 3b75c66f0d2a..05aec40b972e 100644 --- a/ui/src/pages/cloud-workers/cloud-worker-config.ts +++ b/ui/src/pages/cloud-workers/cloud-worker-config.ts @@ -153,6 +153,9 @@ export function buildCloudWorkerUpsertPatch( } const id = editingId ?? draft.id; const existing = isRecord(profiles[id]) ? profiles[id] : {}; + if (editingId && normalizeOptionalString(existing.provider) !== "crabbox") { + return { error: "profileMissing" }; + } const existingSettings = profileSettings(existing); const settings = { ...existingSettings, @@ -161,6 +164,11 @@ export function buildCloudWorkerUpsertPatch( ttl: draft.ttl.trim(), idleTimeout: draft.idleTimeout.trim(), setup: draft.setup.trim() || null, + ...(draft.setup.trim() || + !Array.isArray(existingSettings.setupEnv) || + existingSettings.setupEnv.length === 0 + ? {} + : { setupEnv: null }), desktop: draft.desktop ? true : null, binary: draft.binary.trim() || null, }; diff --git a/ui/src/pages/cloud-workers/cloud-workers-page.ts b/ui/src/pages/cloud-workers/cloud-workers-page.ts index 838e8405f8e8..b34b42102000 100644 --- a/ui/src/pages/cloud-workers/cloud-workers-page.ts +++ b/ui/src/pages/cloud-workers/cloud-workers-page.ts @@ -163,6 +163,10 @@ class CloudWorkersPage extends OpenClawLightDomElement { if (!this.canManage()) { return; } + if (profile.providerId !== "crabbox") { + this.context.navigate("advanced", { search: "?section=cloudWorkers" }); + return; + } this.editor = { kind: "edit", profileId: profile.id }; this.draft = createCloudWorkerDraft(profile); this.formError = null;