Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions __tests__/components/backends/add-backend-modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ describe("AddBackendModal – two-column layout", () => {
expect(submit).not.toBeDisabled();
});

it("allows submitting a local backend with a blank API key", async () => {
it("allows submitting a remote agent server with a blank API key", async () => {
const onClose = vi.fn();
renderWithProviders(<AddBackendModal onClose={onClose} />);

Expand All @@ -107,7 +107,7 @@ describe("AddBackendModal – two-column layout", () => {
name: "Local Extra",
host: "http://127.0.0.1:18002",
apiKey: "",
kind: "local",
kind: "remote",
});
});

Expand All @@ -131,6 +131,37 @@ describe("AddBackendModal – two-column layout", () => {
expect(submit).not.toBeDisabled();
});

it("treats hosted non-cloud agent server URLs as remote", async () => {
renderWithProviders(<AddBackendModal onClose={vi.fn()} />);

const submit = screen.getByTestId(
"add-backend-submit",
) as HTMLButtonElement;
const user = userEvent.setup();

await user.type(screen.getByTestId("add-backend-name"), "Remote Work Host");
await user.type(
screen.getByTestId("add-backend-host"),
"https://work-2-pmmkfqeesqroywhw.prod-runtime.all-hands.dev",
);
expect(submit).not.toBeDisabled();

await user.click(submit);

const stored = JSON.parse(
window.localStorage.getItem("openhands-backends") ?? "[]",
);
const added = stored.find(
(b: { name: string }) => b.name === "Remote Work Host",
);
expect(added).toMatchObject({
name: "Remote Work Host",
host: "https://work-2-pmmkfqeesqroywhw.prod-runtime.all-hands.dev",
apiKey: "",
kind: "remote",
});
});

it("saves the backend, switches to it, and closes", async () => {
const onClose = vi.fn();
renderWithProviders(<AddBackendModal onClose={onClose} />);
Expand All @@ -156,7 +187,7 @@ describe("AddBackendModal – two-column layout", () => {
name: "Local 1",
host: "http://localhost:9000",
apiKey: "k",
kind: "local",
kind: "remote",
});

// Active selection must point at the newly added backend.
Expand Down
40 changes: 35 additions & 5 deletions __tests__/hooks/query/use-automation-health.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,21 @@ import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { useAutomationHealth } from "#/hooks/query/use-automation-health";
import AutomationService from "#/api/automation-service/automation-service.api";

const mockUseActiveBackend = vi.hoisted(() =>
vi.fn(() => ({
backend: { id: "test-backend", kind: "local" },
orgId: null,
})),
);

vi.mock("#/api/automation-service/automation-service.api", () => ({
default: {
checkHealth: vi.fn(),
},
}));

vi.mock("#/contexts/active-backend-context", () => ({
useActiveBackend: () => ({
backend: { id: "test-backend", kind: "local" },
orgId: null,
}),
useActiveBackend: mockUseActiveBackend,
}));

function createWrapper() {
Expand All @@ -33,10 +37,36 @@ function createWrapper() {
describe("useAutomationHealth", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseActiveBackend.mockReturnValue({
backend: { id: "test-backend", kind: "local" },
orgId: null,
});
});

it("returns a synthetic error for remote backends without calling checkHealth", async () => {
mockUseActiveBackend.mockReturnValue({
backend: { id: "remote-backend", kind: "remote" },
orgId: null,
});

const { result } = renderHook(() => useAutomationHealth(), {
wrapper: createWrapper(),
});

await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(result.current.data).toEqual({
status: "error",
message:
"Remote agent servers do not include the local automation sidecar.",
});
expect(AutomationService.checkHealth).not.toHaveBeenCalled();
});

it("should return healthy status when backend is available", async () => {
vi.mocked(AutomationService.checkHealth).mockResolvedValue({ status: "ok" });
vi.mocked(AutomationService.checkHealth).mockResolvedValue({
status: "ok",
});

const { result } = renderHook(() => useAutomationHealth(), {
wrapper: createWrapper(),
Expand Down
31 changes: 18 additions & 13 deletions src/api/backend-registry/active-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import {
writeStoredActiveBackend,
writeStoredBackends,
} from "./storage";
import type { Backend, BackendSelection, ResolvedActiveBackend } from "./types";
import {
isAgentServerBackend,
type Backend,
type BackendSelection,
type ResolvedActiveBackend,
} from "./types";

type Listener = () => void;

Expand All @@ -16,16 +21,16 @@ interface Snapshot {
}

/**
* Pick the local backend the GUI should talk to for local-protocol calls
* (settings, conversations, secrets, …). Prefers the user's first
* registered local backend. As a last resort — when the registry has no
* local entry at all — synthesize one from env/agent-server-config so
* synchronous call sites never have to handle a `null` backend; the
* synthesized entry is never persisted.
* Pick the agent-server backend the GUI should talk to for direct
* agent-server protocol calls (settings, conversations, secrets, …).
* Prefers the user's first registered local or remote backend. As a last
* resort — when the registry has no agent-server entry at all — synthesize
* one from env/agent-server-config so synchronous call sites never have to
* handle a `null` backend; the synthesized entry is never persisted.
*/
function pickLocalBackend(backends: Backend[]): Backend {
const firstLocal = backends.find((b) => b.kind === "local");
return firstLocal ?? makeDefaultLocalBackend();
function pickAgentServerBackend(backends: Backend[]): Backend {
const firstAgentServer = backends.find(isAgentServerBackend);
return firstAgentServer ?? makeDefaultLocalBackend();
}

function computeSnapshot(
Expand All @@ -48,7 +53,7 @@ function computeSnapshot(

// @spec BM-003 — Fallback on active backend removal
if (!activeBackend) {
activeBackend = pickLocalBackend(backends);
activeBackend = pickAgentServerBackend(backends);
activeOrgId = null;
}

Expand Down Expand Up @@ -86,8 +91,8 @@ export function getActiveBackend(): ResolvedActiveBackend {
*/
export function getEffectiveLocalBackend(): Backend {
const active = snapshot.active.backend;
if (active.kind === "local") return active;
return pickLocalBackend(snapshot.backends);
if (isAgentServerBackend(active)) return active;
return pickAgentServerBackend(snapshot.backends);
}

export function getRegisteredBackends(): Backend[] {
Expand Down
11 changes: 7 additions & 4 deletions src/api/backend-registry/auth.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import { getAgentServerSessionApiKey } from "../agent-server-config";
import { DEFAULT_LOCAL_BACKEND_ID } from "./default-backend";
import type { Backend } from "./types";
import { isAgentServerBackend, type Backend } from "./types";

/**
* Build the auth headers to send to a backend.
*
* Local agent-server uses `X-Session-API-Key`. Cloud expects a bearer
* token in the `Authorization` header.
* Local and remote agent servers use `X-Session-API-Key`. Cloud expects a
* bearer token in the `Authorization` header.
*/
export function buildAuthHeaders(backend: Backend): Record<string, string> {
if (backend.kind === "local" && backend.id === DEFAULT_LOCAL_BACKEND_ID) {
if (
isAgentServerBackend(backend) &&
backend.id === DEFAULT_LOCAL_BACKEND_ID
) {
const configuredSessionApiKey = getAgentServerSessionApiKey();
if (configuredSessionApiKey) {
return { "X-Session-API-Key": configuredSessionApiKey };
Expand Down
11 changes: 8 additions & 3 deletions src/api/backend-registry/storage.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { syncBakedSessionApiKey } from "../agent-server-config";
import { makeDefaultLocalBackend } from "./default-backend";
import type { Backend, BackendKind, BackendSelection } from "./types";
import {
isAgentServerBackend,
type Backend,
type BackendKind,
type BackendSelection,
} from "./types";

export const BACKENDS_STORAGE_KEY = "openhands-backends";
export const ACTIVE_BACKEND_STORAGE_KEY = "openhands-active-backend";

function isValidKind(value: unknown): value is BackendKind {
return value === "local" || value === "cloud";
return value === "local" || value === "remote" || value === "cloud";
}

function isValidBackend(value: unknown): value is Backend {
Expand Down Expand Up @@ -35,7 +40,7 @@ function syncDefaultLocalBackendAuth(backend: Backend): Backend {

if (
backend.id !== defaultBackend.id ||
backend.kind !== "local" ||
!isAgentServerBackend(backend) ||
!defaultBackend.apiKey ||
normalizeHostForComparison(backend.host) !==
normalizeHostForComparison(defaultBackend.host)
Expand Down
8 changes: 7 additions & 1 deletion src/api/backend-registry/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
export type BackendKind = "local" | "cloud";
export type BackendKind = "local" | "remote" | "cloud";

export function isAgentServerBackend(
backend: Pick<Backend, "kind">,
): backend is Pick<Backend, "kind"> & { kind: "local" | "remote" } {
return backend.kind === "local" || backend.kind === "remote";
Comment thread
neubig marked this conversation as resolved.
}

export interface Backend {
id: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export function RecommendedAutomationsLauncher({

const prompt = buildAutomationPrompt(
automation.prompt,
activeBackend.backend.kind,
"local",
activeBackend.backend.host,
);

Expand Down Expand Up @@ -144,7 +144,7 @@ export function RecommendedAutomationsLauncher({
);
},
[
activeBackend.backend.kind,
activeBackend.backend.host,
createConversation,
isCreatingConversation,
navigate,
Expand Down Expand Up @@ -210,14 +210,14 @@ export function RecommendedAutomationsLauncher({

const installEntry = installQueue[0] ?? null;

// Recommended automations are a local-backend-only feature; cloud
// automations are managed elsewhere.
if (activeBackend.backend.kind === "cloud") return null;
// Recommended automations require the automation sidecar started by the
// local canvas launcher; remote/cloud backends are managed elsewhere.
if (activeBackend.backend.kind !== "local") return null;

return (
<>
<RecommendedAutomationsSection
backendKind={activeBackend.backend.kind}
backendKind="local"
installedServers={installedMcpServers}
query={query}
onSelect={handleSelectAutomation}
Expand Down
44 changes: 34 additions & 10 deletions src/components/features/backends/backend-form-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import { useBackendsHealth } from "#/hooks/query/use-backends-health";
import { getAgentServerClientOptions } from "#/api/agent-server-client-options";
import ChevronDownSmallIcon from "#/icons/chevron-down-small.svg?react";
import { I18nKey } from "#/i18n/declaration";
import type { Backend, BackendKind } from "#/api/backend-registry/types";
import {
isAgentServerBackend,
type Backend,
type BackendKind,
} from "#/api/backend-registry/types";
import { cn } from "#/utils/utils";
import { BackendStatusDot } from "./backend-status-dot";
import { DeviceFlowAuth } from "./device-flow-auth";
Expand All @@ -31,12 +35,28 @@ interface BackendFormModalProps {
onClose: () => void;
}

// Keep this list to canonical OpenHands Cloud hosts. Other all-hands.dev
// hosts, such as work-* tunnels, may be standalone remote agent servers.

const CLOUD_BACKEND_HOSTS = new Set([
Comment thread
neubig marked this conversation as resolved.
"app.all-hands.dev",
"app.openhands.dev",
"cloud.all-hands.dev",
"cloud.openhands.dev",
]);

function inferKindFromHost(host: string): BackendKind {
const trimmed = host.trim().toLowerCase();
if (trimmed.includes("all-hands.dev") || trimmed.includes("openhands.dev")) {
return "cloud";
try {
const withScheme = /^https?:\/\//i.test(trimmed)
? trimmed
: `https://${trimmed}`;
return CLOUD_BACKEND_HOSTS.has(new URL(withScheme).hostname)
? "cloud"
: "remote";
} catch {
return "remote";
}
return "local";
}

/**
Expand Down Expand Up @@ -149,7 +169,7 @@ function BackendStatusBadge({
},
retry: false,
staleTime: 60_000,
enabled: backend.kind === "local" && !disabled,
enabled: isAgentServerBackend(backend) && !disabled,
});

let statusLabel: string;
Expand All @@ -164,7 +184,9 @@ function BackendStatusBadge({
const kindLabel =
backend.kind === "cloud"
? t(I18nKey.BACKEND$KIND_CLOUD)
: t(I18nKey.BACKEND$KIND_LOCAL);
: backend.kind === "remote"
? t(I18nKey.BACKEND$KIND_REMOTE)
: t(I18nKey.BACKEND$KIND_LOCAL);

return (
<div className="flex flex-col gap-2">
Expand Down Expand Up @@ -293,13 +315,15 @@ export function BackendForm({
const [nameTouched, setNameTouched] = React.useState(false);
const [hostTouched, setHostTouched] = React.useState(false);

// Kind is inferred from the host on every change.
const kind: BackendKind = inferKindFromHost(host);
// Manual additions infer remote/cloud from the host; editing the bundled
// local entry preserves its local/canvas semantics.
const kind: BackendKind =
backend?.kind === "local" ? "local" : inferKindFromHost(host);

const testIdRoot =
explicitTestIdRoot ?? (mode === "edit" ? "edit-backend" : "add-backend");

const needsApiKey = requireApiKey || kind !== "local";
const needsApiKey = requireApiKey || kind === "cloud";
const canSubmit =
name.trim().length > 0 &&
isValidHostUrl(host) &&
Expand Down Expand Up @@ -458,7 +482,7 @@ function ManualConnectionColumn({ onClose }: { onClose: () => void }) {
const canSubmit =
name.trim().length > 0 &&
isValidHostUrl(host) &&
(kind === "local" || apiKey.trim().length > 0);
(kind !== "cloud" || apiKey.trim().length > 0);

const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
Expand Down
Loading
Loading