fix(ui): preserve cloud worker provider-owned settings (#129846)

This commit is contained in:
Peter Steinberger
2026-08-25 22:25:55 -07:00
committed by GitHub
parent 263d2a2a91
commit ced723c2a8
5 changed files with 160 additions and 3 deletions
+72 -2
View File
@@ -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?");
+1 -1
View File
@@ -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",
@@ -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 = {
@@ -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,
};
@@ -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;