fix(backends): hide edit/remove row actions when locked to cloud (#1555)

The Manage Backends modal previously rendered pencil and trash icons on
every backend row even when the deployment was started with
--lock-to-cloud. In locked mode the user should not be able to mutate
the locked backend's host/key/name or remove it outright.

backend-row.tsx now consults getLockedCloudHost() and skips rendering
both action buttons when a locked cloud host is configured. The row
identity (name + host) still renders so the user can see which backend
is locked.

Adds three focused tests to describe("BackendRow", ...):

  * renders edit and remove buttons when not locked to a cloud host
  * hides edit and remove buttons when locked via VITE_LOCK_TO_CLOUD
  * hides edit and remove buttons when locked via the
    window.__AGENT_CANVAS_LOCK_TO_CLOUD__ runtime global

Also extends afterEach with vi.unstubAllEnvs + window-global cleanup
(consistent with backend-form-modal.test.tsx) and isolates three
pre-existing edit-form tests from a local .env that sets
VITE_LOCK_TO_CLOUD, so they keep exercising edit-form behavior rather
than lock behavior.

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Tim O'Farrell
2026-06-30 09:57:46 -06:00
committed by GitHub
co-authored by openhands
parent 74f06866ec
commit cfa2ba5796
3 changed files with 154 additions and 20 deletions
@@ -83,7 +83,6 @@ function TestSeed({
const ctx = useActiveBackendContext();
React.useEffect(() => {
onMount(ctx);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return children as React.ReactElement;
}
@@ -128,6 +127,9 @@ beforeEach(() => {
afterEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
vi.unstubAllEnvs();
delete (window as unknown as Record<string, unknown>)
.__AGENT_CANVAS_LOCK_TO_CLOUD__;
__resetActiveStoreForTests();
__resetHealthStoreForTests();
});
@@ -229,6 +231,13 @@ describe("ManageBackendsModal", () => {
});
it("opens an edit form pre-filled with the row's backend, and persists changes via updateBackend", async () => {
// These tests exercise edit-form behavior, not lock behavior; isolate
// them from a local .env that sets VITE_LOCK_TO_CLOUD and would hide the
// pencil button this test depends on.
vi.stubEnv("VITE_LOCK_TO_CLOUD", "");
delete (window as unknown as Record<string, unknown>)
.__AGENT_CANVAS_LOCK_TO_CLOUD__;
const user = userEvent.setup();
let backendId = "";
@@ -279,6 +288,10 @@ describe("ManageBackendsModal", () => {
});
it("preserves kind:cloud when renaming a cloud backend on a custom domain", async () => {
vi.stubEnv("VITE_LOCK_TO_CLOUD", "");
delete (window as unknown as Record<string, unknown>)
.__AGENT_CANVAS_LOCK_TO_CLOUD__;
const user = userEvent.setup();
let backendId = "";
@@ -328,6 +341,10 @@ describe("ManageBackendsModal", () => {
});
it("closes the edit form when the header close button is clicked", async () => {
vi.stubEnv("VITE_LOCK_TO_CLOUD", "");
delete (window as unknown as Record<string, unknown>)
.__AGENT_CANVAS_LOCK_TO_CLOUD__;
const user = userEvent.setup();
renderWithProviders(
@@ -612,4 +629,115 @@ describe("BackendRow", () => {
cloudBackend.host,
);
});
it("renders edit and remove buttons when the deployment is not locked to a cloud host", async () => {
// The repo's .env may set VITE_LOCK_TO_CLOUD; clear it explicitly so this
// test exercises the unlocked code path regardless of local env state.
vi.stubEnv("VITE_LOCK_TO_CLOUD", "");
delete (window as unknown as Record<string, unknown>)
.__AGENT_CANVAS_LOCK_TO_CLOUD__;
const user = userEvent.setup();
const onEdit = vi.fn();
const onRemove = vi.fn();
renderInQueryClient(
<ul>
<BackendRow
backend={cloudBackend}
health={{
isConnected: true,
consecutiveFailures: 0,
lastError: null,
disabled: false,
}}
onSelect={vi.fn()}
onEdit={onEdit}
onRemove={onRemove}
/>
</ul>,
);
const row = screen.getByTestId(`manage-backends-row-${cloudBackend.name}`);
const editButton = within(row).getByTestId(
`manage-backends-edit-${cloudBackend.name}`,
);
const removeButton = within(row).getByTestId(
`manage-backends-remove-${cloudBackend.name}`,
);
expect(editButton).toHaveAccessibleName("BACKEND$EDIT");
expect(removeButton).toHaveAccessibleName("BACKEND$REMOVE");
await user.click(editButton);
await user.click(removeButton);
expect(onEdit).toHaveBeenCalledTimes(1);
expect(onRemove).toHaveBeenCalledTimes(1);
});
it("hides edit and remove buttons when locked to a cloud host via VITE_LOCK_TO_CLOUD", () => {
vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://cloud.example.com");
const onEdit = vi.fn();
const onRemove = vi.fn();
renderInQueryClient(
<ul>
<BackendRow
backend={cloudBackend}
health={{
isConnected: true,
consecutiveFailures: 0,
lastError: null,
disabled: false,
}}
onSelect={vi.fn()}
onEdit={onEdit}
onRemove={onRemove}
/>
</ul>,
);
const row = screen.getByTestId(`manage-backends-row-${cloudBackend.name}`);
expect(
within(row).queryByTestId(`manage-backends-edit-${cloudBackend.name}`),
).not.toBeInTheDocument();
expect(
within(row).queryByTestId(`manage-backends-remove-${cloudBackend.name}`),
).not.toBeInTheDocument();
});
it("hides edit and remove buttons when locked to a cloud host via window.__AGENT_CANVAS_LOCK_TO_CLOUD__", () => {
(
window as unknown as Record<string, unknown>
).__AGENT_CANVAS_LOCK_TO_CLOUD__ = "https://cloud.example.com";
renderInQueryClient(
<ul>
<BackendRow
backend={cloudBackend}
health={{
isConnected: true,
consecutiveFailures: 0,
lastError: null,
disabled: false,
}}
onSelect={vi.fn()}
onEdit={vi.fn()}
onRemove={vi.fn()}
/>
</ul>,
);
const row = screen.getByTestId(`manage-backends-row-${cloudBackend.name}`);
expect(
within(row).queryByTestId(`manage-backends-edit-${cloudBackend.name}`),
).not.toBeInTheDocument();
expect(
within(row).queryByTestId(`manage-backends-remove-${cloudBackend.name}`),
).not.toBeInTheDocument();
// Row identity (name + host) is still rendered so the user can see the
// locked backend is selected, just not mutate it.
expect(within(row).getByText(cloudBackend.name)).toBeInTheDocument();
expect(within(row).getByText(cloudBackend.host)).toBeInTheDocument();
});
});
@@ -912,7 +912,7 @@ function CloudLoginColumn({
};
return (
<div className="flex flex-1 min-w-0 flex-col items-center gap-3">
<div className="flex flex-1 min-w-0 flex-col items-center gap-3 pb-7">
<div className="flex flex-col items-center gap-1">
<OpenHandsLogoWhite width={56} height={56} aria-hidden />
@@ -13,6 +13,7 @@ import { BackendStatusDot } from "./backend-status-dot";
import { BackendVersion } from "./backend-version";
import { DeviceFlowAuth } from "./device-flow-auth";
import { getBackendStatusLabel } from "./backend-status-label";
import { getLockedCloudHost } from "#/api/agent-server-config";
const ROW_ACTION_BUTTON_CLASS =
"inline-flex cursor-pointer items-center justify-center rounded-md p-1 text-muted transition-colors hover:bg-interactive-hover hover:text-white";
@@ -59,6 +60,7 @@ export function BackendRow({
: "text-[var(--oh-muted)]";
const dotStatus = isInvalidApiKey ? false : (health?.isConnected ?? null);
const canSelect = health?.isConnected === true && !isInvalidApiKey;
const lockedCloudHost = getLockedCloudHost();
return (
<li
@@ -131,24 +133,28 @@ export function BackendRow({
statusDisplay="modal"
/>
) : null}
<button
type="button"
onClick={onEdit}
aria-label={t(I18nKey.BACKEND$EDIT)}
data-testid={`manage-backends-edit-${backend.name}`}
className={ROW_ACTION_BUTTON_CLASS}
>
<Pencil aria-hidden className="size-4" strokeWidth={2} />
</button>
<button
type="button"
onClick={onRemove}
aria-label={t(I18nKey.BACKEND$REMOVE)}
data-testid={`manage-backends-remove-${backend.name}`}
className={ROW_ACTION_BUTTON_CLASS}
>
<Trash2 aria-hidden className="size-4" strokeWidth={2} />
</button>
{!lockedCloudHost && (
<button
type="button"
onClick={onEdit}
aria-label={t(I18nKey.BACKEND$EDIT)}
data-testid={`manage-backends-edit-${backend.name}`}
className={ROW_ACTION_BUTTON_CLASS}
>
<Pencil aria-hidden className="size-4" strokeWidth={2} />
</button>
)}
{!lockedCloudHost && (
<button
type="button"
onClick={onRemove}
aria-label={t(I18nKey.BACKEND$REMOVE)}
data-testid={`manage-backends-remove-${backend.name}`}
className={ROW_ACTION_BUTTON_CLASS}
>
<Trash2 aria-hidden className="size-4" strokeWidth={2} />
</button>
)}
</div>
</li>
);