fix: let users declare self-hosted OHE as a Cloud backend (#1627)

* fix: let users declare self-hosted OHE as a Cloud backend

* fix: failing tests
This commit is contained in:
Hiep Le
2026-07-09 03:15:24 +07:00
committed by GitHub
parent 54d718ad4e
commit 5538b699a7
8 changed files with 244 additions and 24 deletions
+5 -3
View File
@@ -141,9 +141,11 @@ describe("OptionService", () => {
});
it("returns null when VITE_POSTHOG_CLIENT_KEY is not set", async () => {
// No stub — env var is absent in the test environment by default,
// so import.meta.env.VITE_POSTHOG_CLIENT_KEY is undefined,
// and the ?? null fallback applies.
// Force the env var absent so the ?? null fallback applies. A developer's
// local .env may define VITE_POSTHOG_CLIENT_KEY, which vitest loads and
// would otherwise leak into this test; stubbing to undefined deletes it.
vi.stubEnv("VITE_POSTHOG_CLIENT_KEY", undefined);
const config = await OptionService.getConfig();
expect(config.posthog_client_key).toBeNull();
@@ -49,18 +49,19 @@ vi.mock("#/api/cloud/organization-service.api", () => ({
getCurrentCloudApiKey: vi.fn(),
}));
vi.mock("#/api/device-flow-client", () => ({
startDeviceFlow: deviceFlowMocks.startDeviceFlow,
pollForToken: deviceFlowMocks.pollForToken,
DeviceFlowError: class DeviceFlowError extends Error {
code: string;
constructor(message: string, code: string) {
super(message);
this.code = code;
}
},
}));
// Partial mock: only the device-flow network calls are stubbed. The pure
// host-classifier `isOpenHandsCloudHost` (which the backend form uses to infer
// a backend's kind) is inherited from the real module so classification stays
// faithful to production.
vi.mock("#/api/device-flow-client", async (importOriginal) => {
const actual =
await importOriginal<typeof import("#/api/device-flow-client")>();
return {
...actual,
startDeviceFlow: deviceFlowMocks.startDeviceFlow,
pollForToken: deviceFlowMocks.pollForToken,
};
});
// Mock the services useTracking depends on (PostHog client + settings) so the
// consent gate is open and captured events are observable. useTracking itself
@@ -0,0 +1,79 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { BackendConnectionOptions } from "./backend-form-modal";
// Isolate the manual "add backend" column: the sibling Cloud-login column
// drives an OAuth device flow that is irrelevant to backend-kind selection.
vi.mock("./device-flow-auth", () => ({
DeviceFlowAuth: () => null,
}));
afterEach(() => {
vi.clearAllMocks();
});
describe("BackendConnectionOptions — manual backend type", () => {
it("defaults the Type selector to Cloud for an OpenHands Cloud host", () => {
render(
<BackendConnectionOptions
onConnected={vi.fn()}
initialManualBackend={{ host: "https://app.all-hands.dev" }}
/>,
);
expect(screen.getByTestId("add-backend-kind-option-cloud")).toHaveAttribute(
"aria-checked",
"true",
);
expect(screen.getByTestId("add-backend-kind-option-local")).toHaveAttribute(
"aria-checked",
"false",
);
});
it("defaults the Type selector to Local for a self-hosted host on a custom domain", () => {
// A self-hosted OHE on a custom domain is indistinguishable from a local
// agent-server by host alone, so it must not be auto-classified as Cloud.
render(
<BackendConnectionOptions
onConnected={vi.fn()}
initialManualBackend={{ host: "https://app.adorable.build.one" }}
/>,
);
expect(screen.getByTestId("add-backend-kind-option-local")).toHaveAttribute(
"aria-checked",
"true",
);
});
it("persists the user-selected Cloud kind when adding a self-hosted OHE on a custom domain", async () => {
const onConnected = vi.fn();
render(
<BackendConnectionOptions
onConnected={onConnected}
initialManualBackend={{
name: "Adorable Enterprise",
host: "app.adorable.build.one",
apiKey: "oh-cloud-key",
}}
/>,
);
// The user overrides the (Local) default to declare this custom domain a
// Cloud app-server, then connects.
fireEvent.click(screen.getByTestId("add-backend-kind-option-cloud"));
fireEvent.click(screen.getByTestId("add-backend-submit"));
await waitFor(() => expect(onConnected).toHaveBeenCalledTimes(1));
expect(onConnected).toHaveBeenCalledWith(
expect.objectContaining({
kind: "cloud",
name: "Adorable Enterprise",
host: "https://app.adorable.build.one",
apiKey: "oh-cloud-key",
}),
"manual",
);
});
});
@@ -11,12 +11,14 @@ import {
import { ModalCloseButton } from "#/components/shared/modals/modal-close-button";
import { BrandButton } from "#/components/features/settings/brand-button";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { SegmentedToggle } from "#/components/features/files-tab/segmented-toggle";
import { useActiveBackendContext } from "#/contexts/active-backend-context";
import { useNavigation } from "#/context/navigation-context";
import { useBackendsHealth } from "#/hooks/query/use-backends-health";
import { useTracking } from "#/hooks/use-tracking";
import { getAgentServerClientOptions } from "#/api/agent-server-client-options";
import { getLockedCloudHost } from "#/api/agent-server-config";
import { isOpenHandsCloudHost } from "#/api/device-flow-client";
import {
assertAgentServerVersionIsSupported,
getDisplayAgentServerVersion,
@@ -45,12 +47,18 @@ interface BackendFormModalProps {
source?: BackendAddedSource;
}
/**
* Seed the default backend kind from the host. Uses proper hostname-suffix
* matching (via {@link isOpenHandsCloudHost}) rather than a substring test, so
* a look-alike host such as `all-hands-testing.dev` isn't misread as cloud.
*
* This is only a *default*: a self-hosted OpenHands Cloud/Enterprise instance
* on a truly custom domain is indistinguishable from a local agent-server by
* host alone, so the manual add form lets the user override the kind
* explicitly (see the Type selector in ManualConnectionColumn).
*/
function inferKindFromHost(host: string): BackendKind {
const trimmed = host.trim().toLowerCase();
if (trimmed.includes("all-hands.dev") || trimmed.includes("openhands.dev")) {
return "cloud";
}
return "local";
return isOpenHandsCloudHost(host) ? "cloud" : "local";
}
/**
@@ -316,8 +324,15 @@ function useBackendForm({
null,
);
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [kindOverride, setKindOverride] = React.useState<BackendKind | null>(
null,
);
const kind = inferKindFromHost(host);
// Kind follows host inference until the user explicitly picks a type, then
// respects that choice. A custom-domain OHE can't be distinguished from a
// custom-domain local agent-server by host alone, so ManualConnectionColumn
// exposes `setKind` (a Type selector) to let the user declare it.
const kind = kindOverride ?? inferKindFromHost(host);
const needsApiKey = requireApiKey || kind !== "local";
const canSubmit =
name.trim().length > 0 &&
@@ -385,6 +400,7 @@ function useBackendForm({
setConnectionError,
isSubmitting,
kind,
setKind: setKindOverride,
canSubmit,
handleSubmit,
};
@@ -786,6 +802,7 @@ function ManualConnectionColumn({
setConnectionError,
isSubmitting,
kind,
setKind,
canSubmit,
handleSubmit,
} = useBackendForm({
@@ -856,6 +873,20 @@ function ManualConnectionColumn({
</p>
</div>
<div className="flex flex-col items-start gap-2.5">
<span className="text-sm">{t(I18nKey.BACKEND$KIND_LABEL)}</span>
<SegmentedToggle<BackendKind>
value={kind}
options={[
{ value: "local", label: t(I18nKey.BACKEND$KIND_LOCAL) },
{ value: "cloud", label: t(I18nKey.BACKEND$KIND_CLOUD) },
]}
onChange={(value) => setKind(value)}
ariaLabel={t(I18nKey.BACKEND$KIND_LABEL)}
testId={`${testIdRoot}-kind`}
/>
</div>
<SettingsInput
testId={`${testIdRoot}-api-key`}
name={`${testIdRoot}-api-key`}
@@ -96,7 +96,7 @@ export function AgentProfilesLocalView() {
llmProfilesData?.active_profile ?? llmProfiles[0]?.name ?? "";
const existingNames = useMemo(
() => new Set(profilesData?.profiles.map((p) => p.name) ?? []),
() => new Set(profilesData?.profiles?.map((p) => p.name) ?? []),
[profilesData],
);
+1 -1
View File
@@ -16,7 +16,7 @@ export function useActiveAgentProfile(): {
const { data, isLoading } = useAgentProfiles();
const activeId = data?.active_agent_profile_id ?? null;
const activeProfile =
data?.profiles.find((p) => p.id != null && p.id === activeId) ?? null;
data?.profiles?.find((p) => p.id != null && p.id === activeId) ?? null;
return { activeProfile, isLoading };
}
+93
View File
@@ -0,0 +1,93 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { createMemoryRouter, RouterProvider } from "react-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import {
__resetHealthStoreForTests,
recordBackendFailure,
} from "#/api/backend-registry/health-store";
import { MAX_CONSECUTIVE_FAILURES } from "#/api/backend-registry/health-storage";
import { CLOUD_BACKEND_API_KEY_OR_NETWORK_ERROR } from "#/hooks/query/use-backends-health";
import type { Backend } from "#/api/backend-registry/types";
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
import { ONBOARDING_COMPLETED_STORAGE_KEY } from "#/components/features/onboarding/use-onboarding-completion";
import App from "#/root";
// The recovery screen lazy-loads the Manage Backends modal; stub it so the test
// asserts the routing decision rather than the modal's internals.
vi.mock("#/components/features/backends/manage-backends-modal", () => ({
ManageBackendsModal: () => <div data-testid="manage-backends-modal" />,
}));
const cloudBackend: Backend = {
id: "cloud-ohe",
name: "Adorable Enterprise",
host: "https://app.adorable.build.one",
apiKey: "oh-cloud-key",
kind: "cloud",
};
function renderApp() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const router = createMemoryRouter(
[
{
path: "/",
Component: App,
children: [
{ index: true, element: <div data-testid="app-outlet-content" /> },
],
},
],
{ initialEntries: ["/"] },
);
return render(
<QueryClientProvider client={queryClient}>
<ActiveBackendProvider>
<RouterProvider router={router} />
</ActiveBackendProvider>
</QueryClientProvider>,
);
}
describe("App root — active cloud backend connectivity gate", () => {
beforeEach(() => {
localStorage.clear();
__resetHealthStoreForTests();
localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1");
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
});
afterEach(() => {
setActiveSelection(null);
setRegisteredBackends([]);
localStorage.clear();
__resetHealthStoreForTests();
});
it("shows the backend recovery screen when the active cloud backend is unreachable (CORS/network)", async () => {
// Emulate a self-hosted OHE that doesn't allow this frontend's origin:
// repeated CORS/network probe failures until the backend is disabled.
for (let i = 0; i < MAX_CONSECUTIVE_FAILURES; i += 1) {
recordBackendFailure(
cloudBackend.id,
new Error(CLOUD_BACKEND_API_KEY_OR_NETWORK_ERROR),
);
}
renderApp();
expect(
await screen.findByTestId("agent-server-onboarding-screen"),
).toBeInTheDocument();
expect(screen.queryByTestId("app-outlet-content")).not.toBeInTheDocument();
});
});
+15 -1
View File
@@ -28,6 +28,7 @@ import {
import { getEffectiveLocalBackend } from "#/api/backend-registry/active-store";
import { useActiveBackendContext } from "#/contexts/active-backend-context";
import {
isCloudBackendApiKeyOrNetworkHealthError,
isCloudBackendLoggedOutHealthError,
useBackendsHealth,
} from "#/hooks/query/use-backends-health";
@@ -252,6 +253,15 @@ export default function App() {
active.backend.kind === "cloud" &&
activeCloudHealth?.isConnected === false &&
isCloudBackendLoggedOutHealthError(activeCloudHealth.lastError);
// A cloud backend the health probe has given up on (disabled after repeated
// CORS/network failures) is unreachable from this origin — most commonly a
// self-hosted OHE that doesn't allow this frontend's origin. Route to the
// same recovery screen as a logged-out backend so the user sees the real
// connectivity error, not a misleading "LLM not configured" home page.
const activeCloudUnreachable =
active.backend.kind === "cloud" &&
activeCloudHealth?.disabled === true &&
isCloudBackendApiKeyOrNetworkHealthError(activeCloudHealth.lastError);
if (showFirstRunOnboarding) {
return <FirstRunOnboardingScreen onClose={markCompleted} />;
@@ -271,7 +281,11 @@ export default function App() {
return <AgentServerBootstrapLoading />;
}
if (activeCloudLoggedOut || isAgentServerUnavailableError(config.error)) {
if (
activeCloudLoggedOut ||
activeCloudUnreachable ||
isAgentServerUnavailableError(config.error)
) {
return <MissingAgentServerScreen />;
}