Fix Cloud credential expiration recovery (#1383)

* Fix cloud credential expiration recovery

Co-authored-by: openhands <openhands@all-hands.dev>

* Improve cloud re-login dialog styling

Co-authored-by: openhands <openhands@all-hands.dev>

* Use icon-only cloud re-login button

Co-authored-by: openhands <openhands@all-hands.dev>

* Use retry icon for cloud re-login

Co-authored-by: openhands <openhands@all-hands.dev>

* Fix root recovery test for icon login

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: Remove PR-only artifacts

---------

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: allhands-bot <allhands-bot@users.noreply.github.com>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
This commit is contained in:
Graham Neubig
2026-06-16 21:03:03 +00:00
committed by GitHub
co-authored by openhands allhands-bot Rohit Malhotra
parent d09346f8ef
commit 3c40e8fdc4
11 changed files with 554 additions and 100 deletions
@@ -19,6 +19,12 @@ import { ManageBackendsModal } from "#/components/features/backends/manage-backe
import { BackendVersion } from "#/components/features/backends/backend-version";
import { BackendRow } from "#/components/features/backends/backend-row";
import { type Backend } from "#/api/backend-registry/types";
import { CLOUD_BACKEND_LOGGED_OUT_ERROR } from "#/hooks/query/use-backends-health";
const deviceFlowMocks = vi.hoisted(() => ({
startDeviceFlow: vi.fn(),
pollForToken: vi.fn(),
}));
const getServerInfoMock = vi.fn().mockResolvedValue({ version: "1.28.0" });
const getSettingsMock = vi.fn().mockResolvedValue({});
@@ -39,6 +45,19 @@ vi.mock("#/api/cloud/organization-service.api", () => ({
}),
}));
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;
}
},
}));
function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
@@ -71,12 +90,25 @@ beforeEach(() => {
getServerInfoMock.mockResolvedValue({ version: "1.28.0" });
getSettingsMock.mockReset();
getSettingsMock.mockResolvedValue({});
deviceFlowMocks.startDeviceFlow.mockReset();
deviceFlowMocks.startDeviceFlow.mockResolvedValue({
device_code: "device-code",
user_code: "ABCD-EFGH",
verification_uri: "https://app.all-hands.dev/device",
verification_uri_complete:
"https://app.all-hands.dev/device?user_code=ABCD-EFGH",
expires_in: 600,
interval: 5,
});
deviceFlowMocks.pollForToken.mockReset();
deviceFlowMocks.pollForToken.mockImplementation(() => new Promise(() => {}));
__resetActiveStoreForTests();
__resetHealthStoreForTests();
});
afterEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
__resetActiveStoreForTests();
__resetHealthStoreForTests();
});
@@ -140,9 +172,7 @@ describe("ManageBackendsModal", () => {
const onClose = vi.fn();
renderWithProviders(<ManageBackendsModal onClose={onClose} />);
await user.click(
await screen.findByTestId("close-manage-backends-modal"),
);
await user.click(await screen.findByTestId("close-manage-backends-modal"));
expect(onClose).toHaveBeenCalledTimes(1);
});
@@ -271,9 +301,7 @@ describe("ManageBackendsModal", () => {
const stored = JSON.parse(
window.localStorage.getItem("openhands-backends") ?? "[]",
);
const updated = stored.find(
(b: { id: string }) => b.id === backendId,
);
const updated = stored.find((b: { id: string }) => b.id === backendId);
expect(updated).toMatchObject({
name: "OHE Prod Renamed",
kind: "cloud",
@@ -368,4 +396,96 @@ describe("BackendRow", () => {
.closest("button");
expect(selectButton).toBeDisabled();
});
it("shows a logged-out cloud status with a log back in button", () => {
const onLogin = vi.fn();
renderInQueryClient(
<ul>
<BackendRow
backend={cloudBackend}
health={{
isConnected: false,
consecutiveFailures: 1,
lastError: CLOUD_BACKEND_LOGGED_OUT_ERROR,
disabled: false,
}}
onSelect={vi.fn()}
onEdit={vi.fn()}
onRemove={vi.fn()}
onLogin={onLogin}
/>
</ul>,
);
expect(
screen.getByTestId(`manage-backends-status-${cloudBackend.name}`),
).toHaveTextContent("BACKEND$LOGGED_OUT");
expect(
screen.queryByTestId(
`manage-backends-status-detail-${cloudBackend.name}`,
),
).not.toBeInTheDocument();
const loginButton = screen.getByTestId(
`manage-backends-login-${cloudBackend.id}-login-button`,
);
expect(loginButton).toHaveAccessibleName("BACKEND$LOG_BACK_IN");
expect(loginButton).not.toHaveTextContent("BACKEND$LOG_BACK_IN");
expect(loginButton.querySelector("svg")).toBeInTheDocument();
expect(loginButton).toHaveClass("hover:bg-interactive-hover");
expect(loginButton).not.toHaveClass("border");
expect(loginButton).not.toHaveClass("bg-primary");
});
it("shows device authorization in a modal instead of expanding the row", async () => {
const user = userEvent.setup();
const popup = {
closed: false,
close: vi.fn(),
location: { href: "" },
} as unknown as Window;
vi.spyOn(window, "open").mockReturnValue(popup);
renderInQueryClient(
<ul>
<BackendRow
backend={cloudBackend}
health={{
isConnected: false,
consecutiveFailures: 1,
lastError: CLOUD_BACKEND_LOGGED_OUT_ERROR,
disabled: false,
}}
onSelect={vi.fn()}
onEdit={vi.fn()}
onRemove={vi.fn()}
onLogin={vi.fn()}
/>
</ul>,
);
const row = screen.getByTestId(`manage-backends-row-${cloudBackend.name}`);
await user.click(
screen.getByTestId(
`manage-backends-login-${cloudBackend.id}-login-button`,
),
);
const modal = await screen.findByTestId(
`manage-backends-login-${cloudBackend.id}-auth-modal`,
);
expect(
await within(modal).findByTestId(
`manage-backends-login-${cloudBackend.id}-auth-awaiting`,
),
).toBeInTheDocument();
expect(
within(row).queryByTestId(
`manage-backends-login-${cloudBackend.id}-auth-awaiting`,
),
).not.toBeInTheDocument();
expect(deviceFlowMocks.startDeviceFlow).toHaveBeenCalledWith(
cloudBackend.host,
);
});
});
@@ -16,7 +16,10 @@ import {
resetBackendHealth,
} from "#/api/backend-registry/health-store";
import type { Backend } from "#/api/backend-registry/types";
import { useBackendsHealth } from "#/hooks/query/use-backends-health";
import {
CLOUD_BACKEND_LOGGED_OUT_ERROR,
useBackendsHealth,
} from "#/hooks/query/use-backends-health";
const getSettingsMock = vi.fn();
const getServerInfoMock = vi.fn();
@@ -160,7 +163,7 @@ describe("useBackendsHealth", () => {
});
it("reports disconnected when the cloud probe throws", async () => {
getCurrentCloudApiKeyMock.mockRejectedValue(new Error("401"));
getCurrentCloudApiKeyMock.mockRejectedValue(new Error("Network Error"));
const { result } = renderHook(() => useBackendsHealth([cloudBackend]), {
wrapper,
@@ -171,6 +174,26 @@ describe("useBackendsHealth", () => {
);
});
it("reports logged out when the cloud probe returns 401", async () => {
getCurrentCloudApiKeyMock.mockRejectedValue(
Object.assign(new Error("Unauthorized"), {
isAxiosError: true,
response: { status: 401 },
}),
);
const { result } = renderHook(() => useBackendsHealth([cloudBackend]), {
wrapper,
});
await waitFor(() =>
expect(result.current[cloudBackend.id]).toMatchObject({
isConnected: false,
lastError: CLOUD_BACKEND_LOGGED_OUT_ERROR,
}),
);
});
it("reports null while the first probe is still in flight", async () => {
let resolveProbe!: () => void;
getSettingsMock.mockImplementation(
+47
View File
@@ -1,9 +1,13 @@
import { AxiosError } from "axios";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createAgentServerQueryClient } from "#/query-client-config";
import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
afterEach(() => {
window.localStorage.clear();
window.sessionStorage.clear();
__resetActiveStoreForTests();
vi.restoreAllMocks();
});
@@ -42,4 +46,47 @@ describe("createAgentServerQueryClient", () => {
expect(toastSpy).toHaveBeenCalledWith("query error with toast");
});
it("does not show raw 401 toasts while the active cloud backend is logged out", async () => {
const toastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
const backend = {
id: "cloud-expired",
name: "OpenHands Cloud",
host: "https://app.all-hands.dev",
apiKey: "expired-token",
kind: "cloud",
};
window.localStorage.setItem(
"openhands-backends",
JSON.stringify([backend]),
);
window.localStorage.setItem(
"openhands-active-backend",
JSON.stringify({ backendId: backend.id, orgId: null }),
);
window.sessionStorage.setItem(
"openhands-active-backend",
JSON.stringify({ backendId: backend.id, orgId: null }),
);
__resetActiveStoreForTests();
const client = createAgentServerQueryClient();
await expect(
client.fetchQuery({
queryKey: ["cloud", "logged-out"],
queryFn: async () => {
throw new AxiosError(
"Request failed with status code 401",
"ERR_BAD_REQUEST",
undefined,
undefined,
{ status: 401 } as never,
);
},
retry: false,
}),
).rejects.toThrow("Request failed with status code 401");
expect(toastSpy).not.toHaveBeenCalled();
});
});
+52 -9
View File
@@ -9,14 +9,16 @@ import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store"
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
const TRANSLATIONS: Record<string, string> = {
"BACKEND$MANAGE_TITLE": "Manage backends",
"BACKEND$MANAGE_EMPTY": "No backends yet.",
"BACKEND$ADD": "+ Add Backend",
"BACKEND$KIND_LOCAL": "Local",
"BACKEND$KIND_CLOUD": "Cloud",
"BACKEND$EDIT": "Edit",
"BACKEND$REMOVE": "Remove",
"HOME$DONE": "Done",
BACKEND$MANAGE_TITLE: "Manage backends",
BACKEND$MANAGE_EMPTY: "No backends yet.",
BACKEND$ADD: "+ Add Backend",
BACKEND$LOG_BACK_IN: "Log back in",
BACKEND$LOGGED_OUT: "Logged out",
BACKEND$KIND_LOCAL: "Local",
BACKEND$KIND_CLOUD: "Cloud",
BACKEND$EDIT: "Edit",
BACKEND$REMOVE: "Remove",
HOME$DONE: "Done",
};
vi.mock("react-i18next", () => ({
@@ -65,7 +67,6 @@ describe("App root agent-server availability guard", () => {
__resetActiveStoreForTests();
});
it("shows the manage-backends modal when the connected server reports an old version", async () => {
server.use(
http.get("/server_info", () =>
@@ -136,6 +137,48 @@ describe("App root agent-server availability guard", () => {
expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument();
});
it("shows the manage-backends recovery modal when the active cloud backend is logged out", async () => {
const cloudBackend = {
id: "cloud-expired",
name: "OpenHands Cloud",
host: "https://app.all-hands.dev",
apiKey: "expired-token",
kind: "cloud",
};
window.localStorage.setItem(
"openhands-backends",
JSON.stringify([cloudBackend]),
);
window.localStorage.setItem(
"openhands-active-backend",
JSON.stringify({ backendId: cloudBackend.id, orgId: null }),
);
window.sessionStorage.setItem(
"openhands-active-backend",
JSON.stringify({ backendId: cloudBackend.id, orgId: null }),
);
__resetActiveStoreForTests();
server.use(
http.get("https://app.all-hands.dev/api/keys/current", () =>
HttpResponse.json({ detail: "NoCredentialsError" }, { status: 401 }),
),
);
renderApp(["/"]);
await waitFor(() => {
expect(
screen.getByTestId("agent-server-onboarding-screen"),
).toBeInTheDocument();
});
expect(screen.getByTestId("manage-backends-modal")).toBeInTheDocument();
expect(screen.getByText("Logged out")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Log back in" }),
).toBeInTheDocument();
expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument();
});
it("renders the routed page when the agent server is reachable", async () => {
renderApp(["/"]);
@@ -1,8 +1,9 @@
import { useTranslation } from "react-i18next";
import { Pencil, Trash2 } from "lucide-react";
import { Pencil, RefreshCw, Trash2 } from "lucide-react";
import { type Backend } from "#/api/backend-registry/types";
import {
isCloudBackendLoggedOutHealthError,
isInvalidBackendApiKeyHealthError,
type BackendHealth,
} from "#/hooks/query/use-backends-health";
@@ -10,6 +11,7 @@ import { I18nKey } from "#/i18n/declaration";
import { cn } from "#/utils/utils";
import { BackendStatusDot } from "./backend-status-dot";
import { BackendVersion } from "./backend-version";
import { DeviceFlowAuth } from "./device-flow-auth";
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";
@@ -20,6 +22,7 @@ interface BackendRowProps {
onSelect: () => void;
onEdit: () => void;
onRemove: () => void;
onLogin?: (apiKey: string) => void;
}
export function BackendRow({
@@ -28,17 +31,27 @@ export function BackendRow({
onSelect,
onEdit,
onRemove,
onLogin,
}: BackendRowProps) {
const { t } = useTranslation("openhands");
const isInvalidApiKey = isInvalidBackendApiKeyHealthError(health?.lastError);
const isCloudLoggedOut =
backend.kind === "cloud" &&
isCloudBackendLoggedOutHealthError(health?.lastError);
const statusDetail =
!isInvalidApiKey && health?.isConnected === false && health.lastError
!isInvalidApiKey &&
!isCloudLoggedOut &&
health?.isConnected === false &&
health.lastError
? health.lastError
: null;
let statusLabel: string;
let statusClassName = "text-[var(--oh-muted)]";
if (isInvalidApiKey) {
if (isCloudLoggedOut) {
statusLabel = t(I18nKey.BACKEND$LOGGED_OUT);
statusClassName = "text-red-300";
} else if (isInvalidApiKey) {
statusLabel = t(I18nKey.AUTH$INVALID_KEY);
statusClassName = "text-red-300";
} else if (health?.isConnected === true) {
@@ -100,7 +113,22 @@ export function BackendRow({
: t(I18nKey.BACKEND$KIND_LOCAL)}
</span>
</button>
<div className="flex shrink-0 items-center gap-0.5 px-3 py-3">
<div className="flex shrink-0 items-center gap-2 px-3 py-3">
{isCloudLoggedOut && onLogin ? (
<DeviceFlowAuth
host={backend.host}
onSuccess={onLogin}
testIdRoot={`manage-backends-login-${backend.id}`}
idleButtonLabel={t(I18nKey.BACKEND$LOG_BACK_IN)}
idleButtonContent={
<RefreshCw className="size-4" strokeWidth={2} aria-hidden />
}
className="w-auto"
buttonVariant="unstyled"
buttonClassName={ROW_ACTION_BUTTON_CLASS}
statusDisplay="modal"
/>
) : null}
<button
type="button"
onClick={onEdit}
@@ -1,8 +1,18 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { BrandButton } from "#/components/features/settings/brand-button";
import { useDeviceFlow } from "#/hooks/use-device-flow";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { useDeviceFlow, type DeviceFlowStatus } from "#/hooks/use-device-flow";
import { I18nKey } from "#/i18n/declaration";
import { cn } from "#/utils/utils";
type DeviceFlowButtonVariant =
| "primary"
| "secondary"
| "tertiary"
| "unstyled";
type DeviceFlowStatusDisplay = "inline" | "modal";
interface DeviceFlowAuthProps {
/** The host URL for the cloud backend */
@@ -13,6 +23,18 @@ interface DeviceFlowAuthProps {
testIdRoot: string;
/** Whether the login button should be disabled (e.g., when no host is entered) */
isDisabled?: boolean;
/** Override for the idle button label and icon-only accessible name. */
idleButtonLabel?: string;
/** Optional visible content for the idle button. Defaults to the idle label. */
idleButtonContent?: React.ReactNode;
/** Optional classes for the root wrapper. */
className?: string;
/** Optional classes for the idle button. */
buttonClassName?: string;
/** Visual variant for the idle button. */
buttonVariant?: DeviceFlowButtonVariant;
/** Whether in-progress auth content should render inline or in a modal. */
statusDisplay?: DeviceFlowStatusDisplay;
}
/**
@@ -40,6 +62,12 @@ export function DeviceFlowAuth({
onSuccess,
testIdRoot,
isDisabled = false,
idleButtonLabel,
idleButtonContent,
className,
buttonClassName,
buttonVariant = "primary",
statusDisplay = "inline",
}: DeviceFlowAuthProps) {
const { t } = useTranslation("openhands");
const deviceFlow = useDeviceFlow();
@@ -124,98 +152,179 @@ export function DeviceFlowAuth({
deviceFlow.start(fullHost);
};
const handleCancel = () => {
deviceFlow.cancel();
popupRef.current?.close();
};
const statusContent = (
<DeviceFlowStatusContent
status={deviceFlow.status}
error={deviceFlow.error}
verificationUrl={deviceFlow.verificationUrl}
testIdRoot={testIdRoot}
onCancel={handleCancel}
onRetry={handleStartAuth}
/>
);
const showStatusModal =
statusDisplay === "modal" && deviceFlow.status !== "idle";
const idleLabel = idleButtonLabel ?? t(I18nKey.BACKEND$LOGIN_WITH_OPENHANDS);
return (
<div
data-testid={`${testIdRoot}-device-flow`}
className="flex flex-col gap-3"
className={cn("flex flex-col gap-3", className)}
>
{deviceFlow.status === "idle" && (
{deviceFlow.status === "idle" && buttonVariant === "unstyled" && (
<button
type="button"
onClick={handleStartAuth}
data-testid={`${testIdRoot}-login-button`}
className={buttonClassName}
disabled={isDisabled}
aria-label={idleButtonContent ? idleLabel : undefined}
>
{idleButtonContent ?? idleLabel}
</button>
)}
{deviceFlow.status === "idle" && buttonVariant !== "unstyled" && (
<BrandButton
type="button"
variant="primary"
variant={buttonVariant}
onClick={handleStartAuth}
testId={`${testIdRoot}-login-button`}
className="w-full"
className={cn("w-full", buttonClassName)}
isDisabled={isDisabled}
ariaLabel={idleButtonContent ? idleLabel : undefined}
>
{t(I18nKey.BACKEND$LOGIN_WITH_OPENHANDS)}
{idleButtonContent ?? idleLabel}
</BrandButton>
)}
{deviceFlow.status === "starting" && (
<div
className="flex items-center gap-2 p-3 bg-base-tertiary rounded-lg"
data-testid={`${testIdRoot}-auth-starting`}
role="status"
aria-live="polite"
{statusDisplay === "inline" ? statusContent : null}
{showStatusModal ? (
<ModalBackdrop
onClose={handleCancel}
aria-label={t(I18nKey.BACKEND$LOGIN_WITH_OPENHANDS)}
closeOnBackdropClick={false}
>
<ModalBody
testID={`${testIdRoot}-auth-modal`}
width="sm"
className="items-stretch border border-[var(--oh-border)]"
>
{statusContent}
</ModalBody>
</ModalBackdrop>
) : null}
</div>
);
}
interface DeviceFlowStatusContentProps {
status: DeviceFlowStatus;
error: string | null;
verificationUrl: string | null;
testIdRoot: string;
onCancel: () => void;
onRetry: () => void;
}
function DeviceFlowStatusContent({
status,
error,
verificationUrl,
testIdRoot,
onCancel,
onRetry,
}: DeviceFlowStatusContentProps) {
const { t } = useTranslation("openhands");
if (status === "idle" || status === "success") return null;
if (status === "starting") {
return (
<div
className="flex items-center gap-2 rounded-lg bg-base-tertiary p-3"
data-testid={`${testIdRoot}-auth-starting`}
role="status"
aria-live="polite"
>
<LoadingSpinner />
<span className="text-sm text-[var(--oh-text-tertiary)]">
{t(I18nKey.BACKEND$AUTH_STARTING)}
</span>
</div>
);
}
if (status === "awaiting_authorization") {
const validVerificationUrl =
verificationUrl && isValidVerificationUrl(verificationUrl)
? verificationUrl
: null;
return (
<div
className="flex flex-col gap-4 rounded-lg bg-base-tertiary p-4"
data-testid={`${testIdRoot}-auth-awaiting`}
role="status"
aria-live="polite"
>
<div className="flex items-center gap-2">
<LoadingSpinner />
<span className="text-sm text-[var(--oh-text-tertiary)]">
{t(I18nKey.BACKEND$AUTH_STARTING)}
<span className="text-sm font-medium text-white">
{t(I18nKey.BACKEND$AUTH_AWAITING)}
</span>
</div>
)}
{deviceFlow.status === "awaiting_authorization" && (
<div
className="flex flex-col gap-3 p-4 bg-base-tertiary rounded-lg"
data-testid={`${testIdRoot}-auth-awaiting`}
role="status"
aria-live="polite"
>
<div className="flex items-center gap-2">
<LoadingSpinner />
<span className="text-sm font-medium text-white">
{t(I18nKey.BACKEND$AUTH_AWAITING)}
</span>
<p className="text-sm leading-5 text-[var(--oh-text-tertiary)]">
{t(I18nKey.BACKEND$AUTH_BROWSER_OPENED)}
</p>
{validVerificationUrl ? (
<div className="flex flex-col gap-1 rounded-md border border-[var(--oh-border)] bg-[var(--oh-surface)] p-3 text-xs text-[var(--oh-muted)]">
<p>{t(I18nKey.BACKEND$AUTH_OPEN_MANUALLY)}</p>
<a
href={validVerificationUrl}
target="_blank"
rel="noopener noreferrer"
className="max-h-16 overflow-auto break-all text-blue-400 hover:underline"
>
{validVerificationUrl}
</a>
</div>
<p className="text-sm text-[var(--oh-text-tertiary)]">
{t(I18nKey.BACKEND$AUTH_BROWSER_OPENED)}
</p>
{deviceFlow.verificationUrl &&
isValidVerificationUrl(deviceFlow.verificationUrl) && (
<div className="text-xs text-[var(--oh-muted)]">
<p>{t(I18nKey.BACKEND$AUTH_OPEN_MANUALLY)}</p>
<a
href={deviceFlow.verificationUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline break-all"
>
{deviceFlow.verificationUrl}
</a>
</div>
)}
<BrandButton
type="button"
variant="secondary"
onClick={deviceFlow.cancel}
testId={`${testIdRoot}-auth-cancel`}
className="w-full mt-2"
>
{t(I18nKey.BACKEND$AUTH_CANCEL)}
</BrandButton>
</div>
)}
{deviceFlow.status === "error" && (
<div
className="flex flex-col gap-3 p-4 bg-red-900/20 border border-red-700 rounded-lg"
data-testid={`${testIdRoot}-auth-error`}
role="alert"
) : null}
<BrandButton
type="button"
variant="secondary"
onClick={onCancel}
testId={`${testIdRoot}-auth-cancel`}
className="w-full"
>
<p className="text-sm text-red-400">{deviceFlow.error}</p>
<BrandButton
type="button"
variant="secondary"
onClick={handleStartAuth}
testId={`${testIdRoot}-auth-retry`}
className="w-full"
>
{t(I18nKey.BACKEND$AUTH_RETRY)}
</BrandButton>
</div>
)}
{t(I18nKey.BACKEND$AUTH_CANCEL)}
</BrandButton>
</div>
);
}
return (
<div
className="flex flex-col gap-3 rounded-lg border border-red-700 bg-red-900/20 p-4"
data-testid={`${testIdRoot}-auth-error`}
role="alert"
>
<p className="text-sm text-red-400">{error}</p>
<BrandButton
type="button"
variant="secondary"
onClick={onRetry}
testId={`${testIdRoot}-auth-retry`}
className="w-full"
>
{t(I18nKey.BACKEND$AUTH_RETRY)}
</BrandButton>
</div>
);
}
@@ -38,7 +38,7 @@ export function ManageBackendsModal({
recoveryMode = false,
}: ManageBackendsModalProps) {
const { t } = useTranslation("openhands");
const { backends, active, removeBackend, setActive } =
const { backends, active, removeBackend, setActive, updateBackend } =
useActiveBackendContext();
const healthByBackendId = useBackendsHealth(backends, {
probeDisabledOnce: true,
@@ -66,6 +66,13 @@ export function ManageBackendsModal({
[active.backend.id, active.orgId, onClose, setActive],
);
const handleCloudLogin = React.useCallback(
(backend: Backend, apiKey: string) => {
updateBackend(backend.id, { apiKey });
},
[updateBackend],
);
return (
<>
<ModalBackdrop
@@ -119,6 +126,7 @@ export function ManageBackendsModal({
name: backend.name,
})
}
onLogin={(apiKey) => handleCloudLogin(backend, apiKey)}
/>
))}
</ul>
+16 -1
View File
@@ -1,4 +1,5 @@
import React from "react";
import axios from "axios";
import { useQueries } from "@tanstack/react-query";
import {
ServerClient,
@@ -22,6 +23,7 @@ import { MAX_CONSECUTIVE_FAILURES } from "#/api/backend-registry/health-storage"
const REFRESH_INTERVAL_MS = 10000;
const PROBE_TIMEOUT_MS = 4000;
export const INVALID_BACKEND_API_KEY_ERROR = "Invalid API key";
export const CLOUD_BACKEND_LOGGED_OUT_ERROR = "Logged out";
export function isInvalidBackendApiKeyHealthError(
error: string | null | undefined,
@@ -29,6 +31,12 @@ export function isInvalidBackendApiKeyHealthError(
return error === INVALID_BACKEND_API_KEY_ERROR;
}
export function isCloudBackendLoggedOutHealthError(
error: string | null | undefined,
): boolean {
return error === CLOUD_BACKEND_LOGGED_OUT_ERROR;
}
/**
* Probe a single backend for connectivity. The probe path differs by
* backend kind:
@@ -48,7 +56,14 @@ export function isInvalidBackendApiKeyHealthError(
*/
async function probeBackend(backend: Backend): Promise<true> {
if (backend.kind === "cloud") {
await getCurrentCloudApiKey(backend);
try {
await getCurrentCloudApiKey(backend);
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
throw new Error(CLOUD_BACKEND_LOGGED_OUT_ERROR);
}
throw error;
}
return true;
}
+34
View File
@@ -27335,6 +27335,40 @@
"uk": "Увійти через OpenHands Cloud",
"ca": "Inicia sessió amb OpenHands Cloud"
},
"BACKEND$LOG_BACK_IN": {
"en": "Log back in",
"ar": "تسجيل الدخول مجددًا",
"ca": "Torna a iniciar sessió",
"de": "Erneut anmelden",
"es": "Volver a iniciar sesión",
"fr": "Se reconnecter",
"it": "Accedi di nuovo",
"ja": "再ログイン",
"ko-KR": "다시 로그인",
"no": "Logg inn igjen",
"pt": "Entrar novamente",
"tr": "Tekrar giriş yap",
"uk": "Увійти знову",
"zh-CN": "重新登录",
"zh-TW": "重新登入"
},
"BACKEND$LOGGED_OUT": {
"en": "Logged out",
"ar": "تم تسجيل الخروج",
"ca": "Sessió tancada",
"de": "Abgemeldet",
"es": "Sesión cerrada",
"fr": "Déconnecté",
"it": "Disconnesso",
"ja": "ログアウト済み",
"ko-KR": "로그아웃됨",
"no": "Logget ut",
"pt": "Desconectado",
"tr": "Oturum kapatıldı",
"uk": "Вийшли з системи",
"zh-CN": "已退出登录",
"zh-TW": "已登出"
},
"BACKEND$LOGIN_CLOUD_HINT": {
"en": "Works with OpenHands Cloud or your self-hosted OpenHands Cloud deployment.",
"ja": "OpenHands Cloud またはセルフホスト型 OpenHands Cloud デプロイメントで使用できます。",
+16 -2
View File
@@ -4,6 +4,7 @@ import i18n from "#/i18n";
import { I18nKey } from "./i18n/declaration";
import { retrieveAxiosErrorMessage } from "./utils/retrieve-axios-error-message";
import { displayErrorToast } from "./utils/custom-toast-handlers";
import { getActiveBackend } from "#/api/backend-registry/active-store";
import { recordBackendSuccess } from "#/api/backend-registry/health-store";
const handle401Error = (error: AxiosError, client: QueryClient) => {
@@ -12,6 +13,19 @@ const handle401Error = (error: AxiosError, client: QueryClient) => {
}
};
const isActiveCloudBackendAuthError = (error: unknown) => {
if (!(error instanceof AxiosError)) return false;
if (error.response?.status !== 401 && error.status !== 401) return false;
const activeBackend = getActiveBackend().backend;
if (activeBackend.kind !== "cloud") return false;
const requestUrl = error.config?.url;
return (
!requestUrl || requestUrl.startsWith(activeBackend.host.replace(/\/+$/, ""))
);
};
const shownErrors = new Set<string>();
export const createAgentServerQueryClient = () => {
@@ -34,7 +48,7 @@ export const createAgentServerQueryClient = () => {
const disableToast =
query.meta?.disableToast ?? query.options.meta?.disableToast;
if (!disableToast) {
if (!disableToast && !isActiveCloudBackendAuthError(error)) {
const errorMessage = retrieveAxiosErrorMessage(error);
if (!shownErrors.has(errorMessage || "")) {
@@ -55,7 +69,7 @@ export const createAgentServerQueryClient = () => {
const disableToast =
mutation?.meta?.disableToast ?? mutation?.options.meta?.disableToast;
if (!disableToast) {
if (!disableToast && !isActiveCloudBackendAuthError(error)) {
const message = retrieveAxiosErrorMessage(error);
displayErrorToast(message || i18n.t(I18nKey.ERROR$GENERIC));
}
+14 -1
View File
@@ -19,6 +19,11 @@ import {
} from "#/api/agent-server-compatibility";
import { isAuthRequiredAndMissing } from "#/api/agent-server-config";
import { getEffectiveLocalBackend } from "#/api/backend-registry/active-store";
import { useActiveBackendContext } from "#/contexts/active-backend-context";
import {
isCloudBackendLoggedOutHealthError,
useBackendsHealth,
} from "#/hooks/query/use-backends-health";
import { TOAST_OPTIONS } from "#/utils/custom-toast-handlers";
import { TelemetryConsentBanner } from "#/components/features/analytics/telemetry-consent-banner";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
@@ -149,6 +154,14 @@ export default function App() {
// Skip the /server_info probe entirely when we already know auth is
// required and missing — it would just 401 and waste time.
const config = useConfig({ enabled: !authMissing });
const { active } = useActiveBackendContext();
const activeCloudHealth = useBackendsHealth(
active.backend.kind === "cloud" ? [active.backend] : [],
)[active.backend.id];
const activeCloudLoggedOut =
active.backend.kind === "cloud" &&
activeCloudHealth?.isConnected === false &&
isCloudBackendLoggedOutHealthError(activeCloudHealth.lastError);
// No key at all → instant auth screen (no network).
// Stale key → /server_info 401 → auth screen (public mode only).
@@ -164,7 +177,7 @@ export default function App() {
return <AgentServerBootstrapLoading />;
}
if (isAgentServerUnavailableError(config.error)) {
if (activeCloudLoggedOut || isAgentServerUnavailableError(config.error)) {
return <MissingAgentServerScreen />;
}