Files
OpenHands/src/api/backend-registry/storage.ts
T
b9d78d3116 Simplify backend registry selection (#1046)
* Add partial stack modes to agent-canvas

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

* Simplify backend registry selection

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

* Fix backend selection CI regressions

* Seed local proxy backend in cloud tests

* Stabilize onboarding snapshot navigation

* Stabilize ingress tests on Windows

* Remove local backend fallback for cloud calls

* Fix frontend-only backend proxy target

* Test backend-only launch without build

* Add pending workflow and status updates

* Use active LLM profile for setup banner

* Show backend connection errors before saving

* Validate backend keys in health checks

* Update backend selector health test mock

* Fix frontend-only workspace path

* Preserve OpenHands proxy base URL

* Address backend review comments

* Preserve profile config when switching models

* Fail fast on profile export errors

* Throw AgentServerUnavailableError when backend registry is empty

When no backend is configured (empty registry / NO_BACKEND sentinel),
loadAgentServerInfo() was returning null without throwing, causing
OptionService.getConfig() to succeed silently. root.tsx then rendered
the home page instead of the MissingAgentServerScreen with the manage
backends modal.

Now loadAgentServerInfo() checks for the NO_BACKEND sentinel when
getEffectiveLocalBackend() returns null and throws
AgentServerUnavailableError, which root.tsx already handles by showing
the manage backends modal. The cloud-backend path (also null from
getEffectiveLocalBackend) is preserved — it still returns null.

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>
2026-06-03 15:26:51 +00:00

222 lines
5.8 KiB
TypeScript

import {
DEFAULT_LOCAL_BACKEND_ID,
DEFAULT_LOCAL_BACKEND_NAME,
makeDefaultLocalBackend,
} from "./default-backend";
import type { Backend, BackendKind, BackendSelection } from "./types";
export const BACKENDS_STORAGE_KEY = "openhands-backends";
export const ACTIVE_BACKEND_STORAGE_KEY = "openhands-active-backend";
const LEGACY_AGENT_SERVER_CONFIG_STORAGE_KEY = "openhands-agent-server-config";
function isValidKind(value: unknown): value is BackendKind {
return value === "local" || value === "cloud";
}
function isValidBackend(value: unknown): value is Backend {
if (typeof value !== "object" || value === null) return false;
const v = value as Partial<Backend>;
return (
typeof v.id === "string" &&
v.id.length > 0 &&
typeof v.name === "string" &&
typeof v.host === "string" &&
typeof v.apiKey === "string" &&
isValidKind(v.kind)
);
}
function normalizeLegacyBaseUrl(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim().replace(/\/+$/, "");
if (!trimmed) return null;
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `http://${trimmed}`;
}
function readLegacyBackend(): Backend | null {
const raw = window.localStorage.getItem(
LEGACY_AGENT_SERVER_CONFIG_STORAGE_KEY,
);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
const host = normalizeLegacyBaseUrl(parsed.baseUrl);
const apiKey =
typeof parsed.sessionApiKey === "string"
? parsed.sessionApiKey.trim()
: "";
if (!host || !apiKey) return null;
return {
id: DEFAULT_LOCAL_BACKEND_ID,
name: DEFAULT_LOCAL_BACKEND_NAME,
host,
apiKey,
kind: "local",
};
} catch {
return null;
}
}
function clearLegacyBackendConfig(): void {
window.localStorage.removeItem(LEGACY_AGENT_SERVER_CONFIG_STORAGE_KEY);
}
function seedBackends(backends: Backend[]): Backend[] {
writeStoredBackends(backends);
clearLegacyBackendConfig();
return backends;
}
function isLoopbackUrl(value: string): boolean {
try {
const { hostname } = new URL(value);
return (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname === "[::1]"
);
} catch {
return false;
}
}
function shouldSyncLauncherDefaultLocalBackend(
backend: Backend,
defaultBackend: Backend,
): boolean {
if (backend.id !== DEFAULT_LOCAL_BACKEND_ID || backend.kind !== "local") {
return false;
}
return (
backend.host === defaultBackend.host ||
(isLoopbackUrl(backend.host) && isLoopbackUrl(defaultBackend.host))
);
}
function syncLauncherDefaultLocalBackend(backends: Backend[]): Backend[] {
const defaultBackend = makeDefaultLocalBackend();
if (!defaultBackend) return backends;
let didSync = false;
const syncedBackends = backends.map((backend) => {
if (!shouldSyncLauncherDefaultLocalBackend(backend, defaultBackend)) {
return backend;
}
if (backend.apiKey === defaultBackend.apiKey) return backend;
didSync = true;
return {
...backend,
apiKey: defaultBackend.apiKey,
};
});
if (!didSync) return backends;
writeStoredBackends(syncedBackends);
return syncedBackends;
}
export function writeStoredBackends(backends: Backend[]): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(BACKENDS_STORAGE_KEY, JSON.stringify(backends));
} catch {
/* ignore quota / serialization errors */
}
}
export function readStoredBackends(): Backend[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(BACKENDS_STORAGE_KEY);
// First install: migrate one legacy local backend if present, otherwise
// seed only when the launcher supplied enough information for a usable
// local backend.
if (raw === null) {
const legacyBackend = readLegacyBackend();
if (legacyBackend) return seedBackends([legacyBackend]);
const defaultBackend = makeDefaultLocalBackend();
if (!defaultBackend) return [];
return seedBackends([defaultBackend]);
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
const valid = parsed.filter(isValidBackend);
// If the stored array is empty (or everything in it failed validation),
// only re-seed when the launcher supplied both a host and API key.
if (valid.length === 0) {
const defaultBackend = makeDefaultLocalBackend();
if (!defaultBackend) return [];
return seedBackends([defaultBackend]);
}
const synced = syncLauncherDefaultLocalBackend(valid);
clearLegacyBackendConfig();
return synced;
} catch {
return [];
}
}
export function readStoredActiveBackend(): BackendSelection | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(ACTIVE_BACKEND_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (
typeof parsed !== "object" ||
parsed === null ||
typeof (parsed as BackendSelection).backendId !== "string"
) {
return null;
}
const orgIdRaw = (parsed as BackendSelection).orgId;
return {
backendId: (parsed as BackendSelection).backendId,
orgId:
typeof orgIdRaw === "string" && orgIdRaw.length > 0 ? orgIdRaw : null,
};
} catch {
return null;
}
}
export function writeStoredActiveBackend(
selection: BackendSelection | null,
): void {
if (typeof window === "undefined") return;
try {
if (!selection) {
window.localStorage.removeItem(ACTIVE_BACKEND_STORAGE_KEY);
return;
}
window.localStorage.setItem(
ACTIVE_BACKEND_STORAGE_KEY,
JSON.stringify({
backendId: selection.backendId,
orgId: selection.orgId ?? null,
}),
);
} catch {
/* ignore */
}
}