mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 14:58:39 +08:00
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:
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user