diff --git a/.env.sample b/.env.sample index 88656258f..5bbd1dfcc 100644 --- a/.env.sample +++ b/.env.sample @@ -1,12 +1,15 @@ # OpenHands Agent Server target -# These defaults assume you are manually pointing the frontend at a backend on -# 127.0.0.1:8000. The recommended local workflow is `npm run dev`, which starts -# an isolated local backend for this checkout. Use `npm run dev:frontend` only -# when you intentionally want to point at a separately managed backend. -VITE_BACKEND_HOST="127.0.0.1:8000" # Host:port used by the Vite dev proxy -VITE_BACKEND_BASE_URL="http://127.0.0.1:8000" # Base URL used by browser-side direct requests -# VITE_SESSION_API_KEY="" # Set to the same value as backend SESSION_API_KEY or OH_SESSION_API_KEYS_0 when auth is enabled -# VITE_WORKING_DIR="/workspace/project/agent-canvas" # Base dir for per-conversation working_dirs. Each conversation's working_dir is /. Defaults to /workspaces, which is the sibling of the agent server's /conversations/ persistence dir — both share the same per conversation. +# `npm run dev` and `npm run dev:static` inject same-origin launcher config +# automatically. `npm run dev:frontend` and dumb static hosting start +# unconfigured; add a remote backend through the UI. +# +# If you intentionally want a frontend-only Vite server to proxy an existing +# backend through its own origin, uncomment all three values below and set the +# session key to the backend's SESSION_API_KEY / OH_SESSION_API_KEYS_0 value. +# VITE_AGENT_SERVER_TRANSPORT="same-origin" +# VITE_AGENT_SERVER_PROXY_TARGET="127.0.0.1:8000" +# VITE_SESSION_API_KEY="" +# VITE_WORKING_DIR="/workspace/project/agent-canvas" # Base dir for per-conversation working_dirs. Each conversation's working_dir is /. Defaults to workspace/project unless launcher config overrides it. # VITE_WORKER_URLS="" # Optional comma-separated worker URLs for the Browser tab # VITE_ENABLE_BROWSER_TOOLS="true" # Set to false to omit BrowserToolSet from new conversations # VITE_LOAD_PUBLIC_SKILLS="false" # Set to false to disable loading public skills from https://github.com/OpenHands/extensions (on by default) diff --git a/.pr/README.md b/.pr/README.md new file mode 100644 index 000000000..77a52e94f --- /dev/null +++ b/.pr/README.md @@ -0,0 +1,11 @@ +PR-specific QA artifacts for pull request #951. + +These screenshots were captured locally from clean browser profiles and +isolated temporary agent-canvas state directories. + +## Launcher Paths + +- `qa/launch-npm-run-dev.png` - `npm run dev`; Vite-served frontend with launcher-injected same-origin backend. +- `qa/launch-npm-run-dev-frontend.png` - `npm run dev:frontend`; Vite-served frontend with no launcher backend. +- `qa/launch-static-same-origin.png` - `npm run dev:static -- --skip-build`; static frontend served locally with runtime-injected same-origin backend. +- `qa/launch-static-no-backend.png` - `node scripts/static-server.mjs --dir build`; static frontend served without launcher backend, matching dumb static hosting. diff --git a/.pr/qa/launch-npm-run-dev-frontend.png b/.pr/qa/launch-npm-run-dev-frontend.png new file mode 100644 index 000000000..35284500d Binary files /dev/null and b/.pr/qa/launch-npm-run-dev-frontend.png differ diff --git a/.pr/qa/launch-npm-run-dev.png b/.pr/qa/launch-npm-run-dev.png new file mode 100644 index 000000000..a0edd48d0 Binary files /dev/null and b/.pr/qa/launch-npm-run-dev.png differ diff --git a/.pr/qa/launch-static-no-backend.png b/.pr/qa/launch-static-no-backend.png new file mode 100644 index 000000000..35284500d Binary files /dev/null and b/.pr/qa/launch-static-no-backend.png differ diff --git a/.pr/qa/launch-static-same-origin.png b/.pr/qa/launch-static-same-origin.png new file mode 100644 index 000000000..ab6378f56 Binary files /dev/null and b/.pr/qa/launch-static-same-origin.png differ diff --git a/AGENTS.md b/AGENTS.md index 8f9d4fe4e..06fa50e48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,9 +7,10 @@ - `option-service` fabricates an OSS web-client config and reads models/providers through `@openhands/typescript-client` LLM endpoints. - `settings-service` uses `@openhands/typescript-client` settings APIs for persistence; reads schemas from `/api/settings/agent-schema` and `/api/settings/conversation-schema`, fetches settings with optional `X-Expose-Secrets: encrypted` header for conversation start payloads, and saves settings via PATCH with diffs. - `agent-server-conversation-service`, `event-service`, `agent-server-git-service`, and `skills-service` route local agent-server access through `@openhands/typescript-client` rather than direct HTTP calls. -- Supported env vars for deployment: - - `VITE_BACKEND_BASE_URL` for the agent server base URL. - - `VITE_SESSION_API_KEY` for optional session auth. +- Supported frontend/launcher env vars: + - `VITE_AGENT_SERVER_TRANSPORT=same-origin` for launcher-managed same-origin agent servers. Remote agent servers are configured through the UI backend registry. + - `VITE_AGENT_SERVER_PROXY_TARGET` for the Vite dev proxy target when transport is `same-origin`. + - `VITE_SESSION_API_KEY` for same-origin launcher auth. Remote agent-server auth comes from UI config. - `VITE_WORKING_DIR` for the default workspace path sent when starting conversations. - `VITE_WORKER_URLS` as a comma-separated list of browser worker URLs if you want the Browser tab to probe exposed app hosts. - `VITE_ENABLE_BROWSER_TOOLS=false` to omit `BrowserToolSet` from new conversation payloads. @@ -21,15 +22,15 @@ ## Runtime Services in Dev Stacks -- When the agent-canvas dev launchers (`npm run dev` / `dev:minimal` / the published `agent-canvas` binary) start a stack, they set a `VITE_RUNTIME_SERVICES_INFO` env var on the frontend describing which services are running and how the agent should reach them. The frontend forwards this verbatim as `AgentContext.system_message_suffix` on every `POST /api/conversations`, so conversations land with a `` block appended to the system prompt. +- When the agent-canvas dev launchers (`npm run dev` / `dev:minimal` / the published `agent-canvas` binary) start a stack, they provide runtime-services info describing which services are running and how the agent should reach them. Vite dev mode passes this as `VITE_RUNTIME_SERVICES_INFO`; static mode injects it into `window.__AGENT_CANVAS_RUNTIME_CONFIG__` from `scripts/static-server.mjs`. The frontend forwards this info as `AgentContext.system_message_suffix` on every `POST /api/conversations`, so conversations land with a `` block appended to the system prompt. - The block lists URLs **from the agent's point of view**: - The Agent Server is always reachable as `http://localhost:` from inside the sandbox — but that is _you_, not the automation backend. - Host-side services (ingress, Vite, automation) are reachable as `http://localhost:`. - Agents should treat the `` block as authoritative: don't hardcode `localhost:8000` for "the automation server", and don't probe random ports trying to discover services. If the block says automation is not running, skip `/api/automation` calls; otherwise use the listed `url_from_agent` + `api_prefix` (default `/api/automation`) and the `X-API-Key: $OPENHANDS_AUTOMATION_API_KEY` header. - The launcher → frontend → suffix plumbing is: - `scripts/dev-safe.mjs::buildRuntimeServicesInfo()` — pure helper that constructs the info object. - - `scripts/dev-with-automation.mjs::buildAutomationRuntimeServicesInfo()` — wraps it with automation details; called from both Vite spawn (`startVite`) and the static build (`static-build.mjs`). - - `src/api/agent-server-adapter.ts::buildRuntimeServicesSystemSuffix()` reads `VITE_RUNTIME_SERVICES_INFO` and renders the `` markdown block; `createAgentFromSettings()` attaches it to `agent_context.system_message_suffix` when present. + - `scripts/dev-with-automation.mjs::buildAutomationRuntimeServicesInfo()` — wraps it with automation details; called from Vite spawn (`startVite`) and static server startup. + - `src/api/agent-server-adapter.ts::buildRuntimeServicesSystemSuffix()` reads runtime config first, then `VITE_RUNTIME_SERVICES_INFO`, and renders the `` markdown block; `createAgentFromSettings()` attaches it to `agent_context.system_message_suffix` when present. ### `VITE_RUNTIME_SERVICES_INFO` shape @@ -149,7 +150,7 @@ you are running inside of — NOT the automation backend. - A pre-built `build/` directory is required. The Playwright webServer command runs `npm run build:app` when `build/index.html` is absent, but CI should run the build step explicitly for caching (`npm run build:app` in `.github/workflows/mock-llm-e2e.yml`). - **Single ingress URL**: Tests use one URL for both the browser (`baseURL`) and backend API assertions (`BACKEND_URL`). The ingress proxy routes `/api/*` to the agent-server, `/api/automation/*` to the automation backend, and `/*` to the static frontend. Default ingress port for tests is `18300` (override via `MOCK_LLM_INGRESS_PORT` env var). - **State isolation**: `OH_CANVAS_SAFE_STATE_DIR=.tmp/mock-llm-state` isolates test state from the user's real `~/.openhands/agent-canvas/` directory. The directory is cleaned before each test run. -- **Session API key**: A random key is generated per test run and passed to the stack via `SESSION_API_KEY` / `OH_SESSION_API_KEYS_0` / `VITE_SESSION_API_KEY`. The static server injects it into `index.html` at serve time so the frontend authenticates automatically. +- **Session API key**: A random key is generated per test run and passed to the stack via `SESSION_API_KEY` / `OH_SESSION_API_KEYS_0`; Vite mode also receives `VITE_SESSION_API_KEY`, while static mode injects the same key through runtime launcher config in `index.html` so the frontend authenticates automatically. - **Mock LLM server** (`tests/e2e/mock-llm/scripts/mock-llm-server.py`): Python HTTP server using openhands-sdk's `TestLLM` to return scripted tool-call + text trajectories. Supports admin API endpoints for dynamic trajectory management: - `POST /admin/reset` — reset to the default trajectory (terminal printf + text reply) - `POST /admin/trajectory/register` — register a named trajectory (JSON body: `{name, turns}` where each turn is `{tool_call: {name, arguments}}` or `{text: "..."}`) @@ -166,7 +167,7 @@ you are running inside of — NOT the automation backend. ## Additional Notes -- **Published binary auth fix**: When users install the npm package globally (`npm install -g @openhands/agent-canvas`) and run `agent-canvas`, the pre-built static frontend has a `VITE_SESSION_API_KEY` baked in at publish time that differs from the user's persisted runtime key (`~/.openhands/agent-canvas/session-api-key.txt`). The fix is to inject the runtime session key into `index.html` responses at serve time (not build time). `scripts/static-server.mjs` accepts a `--session-api-key ` flag and injects a tiny inline `` ); } @@ -184,7 +201,7 @@ function makeConfigInjectionScript(sessionApiKey) { * Serve index.html with the runtime session key injected into . * Returns true if the response was written, false if the file was not found. */ -async function serveInjectedIndexHtml(req, res, indexPath, sessionApiKey) { +async function serveInjectedIndexHtml(req, res, indexPath, config) { let content; try { content = await readFile(indexPath, "utf8"); @@ -192,7 +209,10 @@ async function serveInjectedIndexHtml(req, res, indexPath, sessionApiKey) { return false; } - const script = makeConfigInjectionScript(sessionApiKey); + const script = makeConfigInjectionScript( + config.runtimeConfig, + config.sessionApiKey, + ); // Inject right before so the key is available before any app code runs. // replace() targets the first (and only) in well-formed HTML. const injected = content.includes("") @@ -393,7 +413,7 @@ async function serveFile(req, res, filePath, urlPath) { return true; } -async function handleStatic(req, res, dirAbs, sessionApiKey = null) { +async function handleStatic(req, res, dirAbs, runtimeConfig = {}) { const rawPath = req.url.split("?")[0]; let urlPath; try { @@ -417,9 +437,12 @@ async function handleStatic(req, res, dirAbs, sessionApiKey = null) { filePath = resolve(filePath, "index.html"); } - // Serve index.html with runtime key injection when a session key is configured. - if (sessionApiKey && filePath.endsWith("index.html")) { - if (await serveInjectedIndexHtml(req, res, filePath, sessionApiKey)) return; + // Serve index.html with runtime config injected before the app bundle runs. + if ( + (runtimeConfig.runtimeConfig || runtimeConfig.sessionApiKey) && + filePath.endsWith("index.html") + ) { + if (await serveInjectedIndexHtml(req, res, filePath, runtimeConfig)) return; // Fall through to regular serveFile (handles 404 path correctly). } @@ -431,8 +454,8 @@ async function handleStatic(req, res, dirAbs, sessionApiKey = null) { !looksLikeAssetRequest(urlPath) ) { const indexPath = resolve(dirAbs, "index.html"); - if (sessionApiKey) { - if (await serveInjectedIndexHtml(req, res, indexPath, sessionApiKey)) + if (runtimeConfig.runtimeConfig || runtimeConfig.sessionApiKey) { + if (await serveInjectedIndexHtml(req, res, indexPath, runtimeConfig)) return; } else if (await serveFile(req, res, indexPath, "/")) return; } @@ -448,7 +471,10 @@ async function handleStatic(req, res, dirAbs, sessionApiKey = null) { export function startStaticServer(config) { const route = createRouter(config.routes); const dirAbs = resolve(config.dir); - const sessionApiKey = config.sessionApiKey || null; + const runtimeConfig = { + runtimeConfig: config.runtimeConfig ?? null, + sessionApiKey: config.sessionApiKey || null, + }; const server = createServer((req, res) => { const backend = route(req.url); @@ -456,7 +482,7 @@ export function startStaticServer(config) { proxyRequest(req, res, backend); return; } - handleStatic(req, res, dirAbs, sessionApiKey).catch((err) => { + handleStatic(req, res, dirAbs, runtimeConfig).catch((err) => { console.error(`Static handler error for ${req.url}:`, err); if (!res.headersSent) { res.writeHead(500); diff --git a/src/api/agent-canvas-runtime-config.ts b/src/api/agent-canvas-runtime-config.ts new file mode 100644 index 000000000..0262cbbed --- /dev/null +++ b/src/api/agent-canvas-runtime-config.ts @@ -0,0 +1,34 @@ +import type { AgentServerTransport } from "./backend-registry/types"; + +export const AGENT_CANVAS_RUNTIME_CONFIG_GLOBAL = + "__AGENT_CANVAS_RUNTIME_CONFIG__"; + +export interface RuntimeAgentServerConfig { + transport?: AgentServerTransport | null; + sessionApiKey?: string | null; + workingDir?: string | null; +} + +export interface AgentCanvasRuntimeConfig { + agentServer?: RuntimeAgentServerConfig | null; + runtimeServicesInfo?: unknown; +} + +type RuntimeConfigWindow = Window & { + [AGENT_CANVAS_RUNTIME_CONFIG_GLOBAL]?: unknown; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function getAgentCanvasRuntimeConfig(): AgentCanvasRuntimeConfig { + if (typeof window === "undefined") return {}; + + const value = (window as RuntimeConfigWindow)[ + AGENT_CANVAS_RUNTIME_CONFIG_GLOBAL + ]; + if (!isRecord(value)) return {}; + + return value as AgentCanvasRuntimeConfig; +} diff --git a/src/api/agent-server-adapter.ts b/src/api/agent-server-adapter.ts index 6f7a3081a..33fdab013 100644 --- a/src/api/agent-server-adapter.ts +++ b/src/api/agent-server-adapter.ts @@ -11,6 +11,7 @@ import { getAgentServerWorkingDir, shouldLoadPublicSkills, } from "./agent-server-config"; +import { getAgentCanvasRuntimeConfig } from "./agent-canvas-runtime-config"; import { getEffectiveLocalBackend } from "./backend-registry/active-store"; import { buildAuthHeaders } from "./backend-registry/auth"; import { @@ -123,7 +124,15 @@ interface RuntimeServicesInfo { } function parseRuntimeServicesInfo(): RuntimeServicesInfo | null { - const raw = import.meta.env.VITE_RUNTIME_SERVICES_INFO?.trim(); + const runtimeInfo = getAgentCanvasRuntimeConfig().runtimeServicesInfo; + if (runtimeInfo && typeof runtimeInfo === "object") { + return runtimeInfo as RuntimeServicesInfo; + } + + const raw = + typeof runtimeInfo === "string" + ? runtimeInfo.trim() + : import.meta.env.VITE_RUNTIME_SERVICES_INFO?.trim(); if (!raw) return null; try { const parsed = JSON.parse(raw) as RuntimeServicesInfo; @@ -439,6 +448,10 @@ function isToolRecord( } function shouldIncludeTool(name: string, agentSettings: SettingsRecord) { + if (name === CANVAS_UI_TOOL_NAME) { + return isAgentServerToolAvailable(name); + } + if (name === BROWSER_TOOL_SET_NAME) { return browserToolsEnabled() && isAgentServerToolAvailable(name); } @@ -457,7 +470,9 @@ function getAgentTools(agentSettings: SettingsRecord): AgentToolSpec[] { const tools = new Map(); for (const name of DEFAULT_TOOL_NAMES) { - tools.set(name, { name, params: {} }); + if (shouldIncludeTool(name, agentSettings)) { + tools.set(name, { name, params: {} }); + } } for (const name of [BROWSER_TOOL_SET_NAME, TASK_TOOL_SET_NAME]) { @@ -780,12 +795,19 @@ export function buildStartConversationRequest( payload.hook_config = conversationSettings.hook_config; } - payload.tool_module_qualnames = { - [CANVAS_UI_TOOL_NAME]: CANVAS_UI_TOOL_MODULE, - ...((conversationSettings.tool_module_qualnames as + const toolModuleQualnames: Record = {}; + if (isAgentServerToolAvailable(CANVAS_UI_TOOL_NAME)) { + toolModuleQualnames[CANVAS_UI_TOOL_NAME] = CANVAS_UI_TOOL_MODULE; + } + Object.assign( + toolModuleQualnames, + (conversationSettings.tool_module_qualnames as | Record - | undefined) ?? {}), - }; + | undefined) ?? {}, + ); + if (Object.keys(toolModuleQualnames).length > 0) { + payload.tool_module_qualnames = toolModuleQualnames; + } if (conversationSettings.agent_definitions) { payload.agent_definitions = conversationSettings.agent_definitions; diff --git a/src/api/agent-server-client-options.ts b/src/api/agent-server-client-options.ts index c594fcaff..531b189cb 100644 --- a/src/api/agent-server-client-options.ts +++ b/src/api/agent-server-client-options.ts @@ -1,11 +1,8 @@ import { buildHttpBaseUrl } from "#/utils/websocket-url"; -import { - getAgentServerSessionApiKey, - getAgentServerWorkingDir, -} from "./agent-server-config"; +import { getAgentServerWorkingDir } from "./agent-server-config"; import { getEffectiveLocalBackend } from "./backend-registry/active-store"; -import { DEFAULT_LOCAL_BACKEND_ID } from "./backend-registry/default-backend"; -import type { Backend } from "./backend-registry/types"; +import { getBackendSessionApiKey } from "./backend-registry/auth"; +import { getBackendBaseUrl, type Backend } from "./backend-registry/types"; export interface AgentServerClientOverrides { host?: string; @@ -34,21 +31,17 @@ function resolveHost( if (overrides.host) return normalizeHost(overrides.host); if (overrides.conversationUrl) return normalizeHost(buildHttpBaseUrl(overrides.conversationUrl)); - return normalizeHost(backend.host); + return normalizeHost(getBackendBaseUrl(backend)); } export function getAgentServerClientOptions( overrides: AgentServerClientOverrides = {}, ): AgentServerClientOptions { const backend = getEffectiveLocalBackend(); - const configuredSessionApiKey = getAgentServerSessionApiKey(); - const defaultLocalApiKeyOverride = - backend.id === DEFAULT_LOCAL_BACKEND_ID ? configuredSessionApiKey : null; const apiKey = overrides.sessionApiKey ?? overrides.apiKey ?? - defaultLocalApiKeyOverride ?? - backend.apiKey ?? + getBackendSessionApiKey(backend) ?? undefined; return { diff --git a/src/api/agent-server-compatibility.ts b/src/api/agent-server-compatibility.ts index 57d1606b4..342bbb9a6 100644 --- a/src/api/agent-server-compatibility.ts +++ b/src/api/agent-server-compatibility.ts @@ -1,14 +1,27 @@ -import { ServerClient } from "@openhands/typescript-client/clients"; +import { + ServerClient, + SettingsClient, +} from "@openhands/typescript-client/clients"; import type { ServerInfo as BaseServerInfo } from "@openhands/typescript-client"; import { getAgentServerClientOptions } from "#/api/agent-server-client-options"; -import { getEffectiveLocalBackend } from "#/api/backend-registry/active-store"; +import { + getEffectiveLocalBackend, + hasEffectiveLocalBackend, +} from "#/api/backend-registry/active-store"; +import { getBackendBaseUrl, type Backend } from "#/api/backend-registry/types"; +import { maybeCreateAgentServerCorsError } from "#/utils/agent-server-cors-error"; const AGENT_SERVER_INFO_TIMEOUT_MS = 5000; +const MAX_AGENT_SERVER_ERROR_DETAIL_LENGTH = 240; +const HTML_DOCUMENT_RESPONSE_DETAIL = + "The server returned an HTML page instead of an agent-server API response."; export interface AgentServerInfo extends BaseServerInfo { usable_tools?: string[] | null; } +export type AgentServerUnavailableReason = "unauthorized" | "unreachable"; + let cachedAgentServerInfo: AgentServerInfo | null = null; const getAdvertisedTools = (serverInfo: AgentServerInfo | null) => { @@ -20,13 +33,21 @@ const getAdvertisedTools = (serverInfo: AgentServerInfo | null) => { export class AgentServerUnavailableError extends Error { readonly details: string | null; + readonly reason: AgentServerUnavailableReason; + readonly status: number | null; - constructor(details?: string | null) { + constructor( + details?: string | null, + reason: AgentServerUnavailableReason = "unreachable", + status?: number | null, + ) { super( "Agent server not found. Could not connect to the configured agent server. Start a compatible agent server and reload the page.", ); this.name = "AgentServerUnavailableError"; this.details = details ?? null; + this.reason = reason; + this.status = status ?? null; } } @@ -51,7 +72,7 @@ export function isAgentServerToolAvailable(toolName: string) { return availableTools.includes(toolName); } -function isSdkHttpError(error: unknown) { +function isSdkHttpError(error: unknown): error is Error & { status: number } { return ( error instanceof Error && error.name === "HttpError" && @@ -60,32 +81,115 @@ function isSdkHttpError(error: unknown) { ); } +function getUnavailableReason( + error: unknown, +): Pick { + if (isSdkHttpError(error)) { + return { + reason: + error.status === 401 || error.status === 403 + ? "unauthorized" + : "unreachable", + status: error.status, + }; + } + + return { reason: "unreachable", status: null }; +} + +function containsHtmlDocument(value: string): boolean { + return /(?: Boolean(url)); } -export function getAgentServerHeaders(): Record { - const sessionApiKey = getAgentServerSessionApiKey(); - return sessionApiKey ? { "X-Session-API-Key": sessionApiKey } : {}; -} - /** * Returns whether public skills from the OpenHands extensions marketplace * (https://github.com/OpenHands/extensions) should be loaded. diff --git a/src/api/automation-service/automation-service.api.ts b/src/api/automation-service/automation-service.api.ts index 282cd19a2..4ae400414 100644 --- a/src/api/automation-service/automation-service.api.ts +++ b/src/api/automation-service/automation-service.api.ts @@ -9,6 +9,7 @@ import { getActiveBackend, getEffectiveLocalBackend, } from "../backend-registry/active-store"; +import { getBackendBaseUrl } from "../backend-registry/types"; import { callCloudProxy } from "../cloud/proxy"; const AUTOMATION_BASE_PATH = "/api/automation"; @@ -35,7 +36,7 @@ localAutomationAxios.interceptors.request.use((config) => { // the 401 errors reported in issue #829. const backend = getEffectiveLocalBackend(); // eslint-disable-next-line no-param-reassign - if (!config.baseURL) config.baseURL = backend.host; + if (!config.baseURL) config.baseURL = getBackendBaseUrl(backend); const apiKey = backend.apiKey?.trim(); if (apiKey) { diff --git a/src/api/backend-registry/active-store.ts b/src/api/backend-registry/active-store.ts index 78d0c5999..cbceed931 100644 --- a/src/api/backend-registry/active-store.ts +++ b/src/api/backend-registry/active-store.ts @@ -1,4 +1,5 @@ import { makeDefaultLocalBackend } from "./default-backend"; +import { hasConfiguredAgentServerDefaults } from "../agent-server-config"; import { readStoredActiveBackend, readStoredBackends, @@ -16,15 +17,14 @@ 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 agent-server + * protocol calls (settings, conversations, secrets, ...). Prefers the user's + * first registered agent-server backend. As a last resort, synthesize one from + * env/agent-server-config so synchronous call sites never have to handle a + * `null` backend; the synthetic entry is never persisted. */ function pickLocalBackend(backends: Backend[]): Backend { - const firstLocal = backends.find((b) => b.kind === "local"); + const firstLocal = backends.find((b) => b.kind === "agent-server"); return firstLocal ?? makeDefaultLocalBackend(); } @@ -75,21 +75,28 @@ export function getActiveBackend(): ResolvedActiveBackend { } /** - * Pick the backend to use for *local agent-server protocol* calls. + * Pick the backend to use for agent-server protocol calls. * * Most of the GUI's services (settings reads/writes, conversation CRUD, - * skills/MCP/secrets, etc.) speak the local agent-server's protocol — - * they would fail against a cloud host. When the user has chosen a - * cloud backend as active, those calls fall back to the first registered - * local backend (or the env-derived default if none exists). Cloud-only - * call sites import `getActiveBackend` directly. + * skills/MCP/secrets, etc.) speak the agent-server protocol; they would fail + * against a cloud host. When the user has chosen a cloud backend as active, + * those calls fall back to the first registered agent-server backend (or the + * env-derived default if none exists). Cloud-only call sites import + * `getActiveBackend` directly. */ export function getEffectiveLocalBackend(): Backend { const active = snapshot.active.backend; - if (active.kind === "local") return active; + if (active.kind === "agent-server") return active; return pickLocalBackend(snapshot.backends); } +export function hasEffectiveLocalBackend(): boolean { + return ( + snapshot.backends.some((backend) => backend.kind === "agent-server") || + hasConfiguredAgentServerDefaults() + ); +} + export function getRegisteredBackends(): Backend[] { return snapshot.backends; } diff --git a/src/api/backend-registry/auth.ts b/src/api/backend-registry/auth.ts index 3b2d47b98..19a185686 100644 --- a/src/api/backend-registry/auth.ts +++ b/src/api/backend-registry/auth.ts @@ -1,26 +1,43 @@ -import { getAgentServerSessionApiKey } from "../agent-server-config"; +import { + getAgentServerTransport, + getLauncherAgentServerSessionApiKey, +} from "../agent-server-config"; import { DEFAULT_LOCAL_BACKEND_ID } from "./default-backend"; import type { Backend } from "./types"; +function isSameOriginAgentServer(backend: Backend): boolean { + if (backend.kind !== "agent-server") return false; + if (backend.agentServerTransport) { + return backend.agentServerTransport === "same-origin"; + } + + return ( + backend.id === DEFAULT_LOCAL_BACKEND_ID && + getAgentServerTransport() === "same-origin" + ); +} + +export function getBackendSessionApiKey(backend: Backend): string | null { + if (backend.kind !== "agent-server") return null; + + if (isSameOriginAgentServer(backend)) { + return getLauncherAgentServerSessionApiKey(); + } + + return backend.apiKey?.trim() || null; +} + /** * 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. + * Agent-server backends use `X-Session-API-Key`. Cloud expects a bearer token + * in the `Authorization` header. */ export function buildAuthHeaders(backend: Backend): Record { - if (backend.kind === "local" && backend.id === DEFAULT_LOCAL_BACKEND_ID) { - const configuredSessionApiKey = getAgentServerSessionApiKey(); - if (configuredSessionApiKey) { - return { "X-Session-API-Key": configuredSessionApiKey }; - } - } - - if (!backend.apiKey) return {}; - if (backend.kind === "cloud") { - return { Authorization: `Bearer ${backend.apiKey}` }; + return backend.apiKey ? { Authorization: `Bearer ${backend.apiKey}` } : {}; } - return { "X-Session-API-Key": backend.apiKey }; + const sessionApiKey = getBackendSessionApiKey(backend); + return sessionApiKey ? { "X-Session-API-Key": sessionApiKey } : {}; } diff --git a/src/api/backend-registry/default-backend.ts b/src/api/backend-registry/default-backend.ts index 7a1db147c..eeb5e4d17 100644 --- a/src/api/backend-registry/default-backend.ts +++ b/src/api/backend-registry/default-backend.ts @@ -1,14 +1,15 @@ import { getAgentServerBaseUrl, getAgentServerSessionApiKey, + getAgentServerTransport, } from "../agent-server-config"; import type { Backend } from "./types"; /** - * Stable id for the default local backend that is auto-seeded into the - * registry on a fresh install. After seeding, this backend is a normal - * registered entry — the user can rename it, edit its host/api key, or - * remove it like any other backend. + * Stable id for the package-provided agent-server backend that is seeded into + * the registry on a fresh install. After seeding, this backend is a normal + * registered entry — the user can rename it, edit its host/api key, or remove + * it like any other backend. * * The id is also used by `saveAgentServerConfig` to keep the registry * entry in sync with the legacy `openhands-agent-server-config` storage @@ -19,14 +20,14 @@ export const DEFAULT_LOCAL_BACKEND_ID = "default-local"; export const DEFAULT_LOCAL_BACKEND_NAME = "Local"; /** - * Construct the default local backend from environment / agent-server - * config (`VITE_BACKEND_BASE_URL`, `VITE_SESSION_API_KEY`, plus the - * `openhands-agent-server-config` localStorage overrides). + * Construct the package-provided agent-server backend from launcher config. + * Vite dev launchers provide this through build-time `VITE_*` values; static + * launchers provide it through `window.__AGENT_CANVAS_RUNTIME_CONFIG__`. * * Used in two places: * 1. As the seed entry written to `openhands-backends` on first load. * 2. As a last-resort fallback inside the active store when the - * registry has no local backend at all (e.g. the user removed + * registry has no agent-server backend at all (e.g. the user removed * every entry). The synthetic fallback is never persisted. */ export function makeDefaultLocalBackend(): Backend { @@ -35,6 +36,7 @@ export function makeDefaultLocalBackend(): Backend { name: DEFAULT_LOCAL_BACKEND_NAME, host: getAgentServerBaseUrl(), apiKey: getAgentServerSessionApiKey() ?? "", - kind: "local", + kind: "agent-server", + agentServerTransport: getAgentServerTransport(), }; } diff --git a/src/api/backend-registry/storage.ts b/src/api/backend-registry/storage.ts index cb89f9b54..ac121cc93 100644 --- a/src/api/backend-registry/storage.ts +++ b/src/api/backend-registry/storage.ts @@ -1,55 +1,70 @@ import { makeDefaultLocalBackend } from "./default-backend"; -import type { Backend, BackendKind, BackendSelection } from "./types"; +import { hasConfiguredAgentServerDefaults } from "../agent-server-config"; +import type { + AgentServerTransport, + Backend, + BackendKind, + 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"; +function normalizeBackendKind(value: unknown): BackendKind | null { + if (value === "agent-server" || value === "cloud") return value; + if (value === "local") return "agent-server"; + return null; } -function isValidBackend(value: unknown): value is Backend { - if (typeof value !== "object" || value === null) return false; +function normalizeAgentServerTransport( + value: unknown, +): AgentServerTransport | null { + if (value === "same-origin" || value === "remote") return value; + if (value === "packaged") return "same-origin"; + if (value === "separate") return "remote"; + return null; +} + +function normalizeBackend(value: unknown): Backend | null { + if (typeof value !== "object" || value === null) return null; const v = value as Partial; - return ( + const rawKind = (value as { kind?: unknown }).kind; + const rawAgentServerTransport = ( + value as { + agentServerTransport?: unknown; + agentServerSource?: unknown; + } + ).agentServerTransport; + const rawAgentServerSource = (value as { agentServerSource?: unknown }) + .agentServerSource; + if ( typeof v.id === "string" && v.id.length > 0 && typeof v.name === "string" && typeof v.host === "string" && typeof v.apiKey === "string" && - isValidKind(v.kind) - ); -} - -function normalizeHostForComparison(host: string): string { - try { - return new URL(host).origin; - } catch { - return host.replace(/\/+$/, ""); - } -} - -function syncDefaultLocalBackendAuth(backend: Backend): Backend { - const defaultBackend = makeDefaultLocalBackend(); - - if ( - backend.id !== defaultBackend.id || - backend.kind !== "local" || - !defaultBackend.apiKey || - normalizeHostForComparison(backend.host) !== - normalizeHostForComparison(defaultBackend.host) + normalizeBackendKind(rawKind) ) { - return backend; - } + const kind = normalizeBackendKind(rawKind); + if (!kind) return null; + const agentServerTransport = + kind === "agent-server" + ? normalizeAgentServerTransport( + rawAgentServerTransport ?? rawAgentServerSource, + ) + : undefined; - if (backend.apiKey === defaultBackend.apiKey) { - return backend; + return { + id: v.id, + name: v.name, + host: v.host, + apiKey: v.apiKey, + kind, + ...(agentServerTransport ? { agentServerTransport } : {}), + }; } - return { - ...backend, - apiKey: defaultBackend.apiKey, - }; + return null; } export function writeStoredBackends(backends: Backend[]): void { @@ -66,11 +81,11 @@ export function readStoredBackends(): Backend[] { try { const raw = window.localStorage.getItem(BACKENDS_STORAGE_KEY); - // First install: the storage key has never been written. Seed the - // registry with one default local backend derived from the env / - // agent-server-config so the user has something to talk to out of - // the box. + // First install: only seed a package-provided agent-server backend when + // deployment config actually provided backend defaults. Frontend-only dev + // should start with an empty registry so no backend looks preconfigured. if (raw === null) { + if (!hasConfiguredAgentServerDefaults()) return []; const seeded = [makeDefaultLocalBackend()]; writeStoredBackends(seeded); return seeded; @@ -78,26 +93,20 @@ export function readStoredBackends(): Backend[] { const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return []; - const valid = parsed.filter(isValidBackend); + const valid = parsed + .map(normalizeBackend) + .filter((backend): backend is Backend => backend !== null); // If the stored array is empty (or everything in it failed validation), - // re-seed with the default Local backend so the user always has a - // working entry pointing at VITE_SESSION_API_KEY. With the dev scripts - // persisting that key to ~/.openhands/agent-canvas/session-api-key.txt, - // re-seeding is safe — the seeded entry will keep working across - // restarts instead of going stale. + // re-seed only when deployment config provided backend defaults. if (valid.length === 0) { + if (!hasConfiguredAgentServerDefaults()) return []; const seeded = [makeDefaultLocalBackend()]; writeStoredBackends(seeded); return seeded; } - const synced = valid.map(syncDefaultLocalBackendAuth); - if (synced.some((backend, index) => backend !== valid[index])) { - writeStoredBackends(synced); - } - - return synced; + return valid; } catch { return []; } diff --git a/src/api/backend-registry/types.ts b/src/api/backend-registry/types.ts index fb50708df..51c43ed8c 100644 --- a/src/api/backend-registry/types.ts +++ b/src/api/backend-registry/types.ts @@ -1,4 +1,6 @@ -export type BackendKind = "local" | "cloud"; +export type BackendKind = "agent-server" | "cloud"; +export type AgentServerTransport = "same-origin" | "remote"; +export type BackendConnectionKind = AgentServerTransport | "cloud"; export interface Backend { id: string; @@ -6,6 +8,31 @@ export interface Backend { host: string; apiKey: string; kind: BackendKind; + agentServerTransport?: AgentServerTransport; +} + +type BackendBaseUrlInput = Pick< + Backend, + "host" | "kind" | "agentServerTransport" +>; + +export function getBackendConnectionKind( + backend: Backend, +): BackendConnectionKind { + if (backend.kind === "cloud") return "cloud"; + return backend.agentServerTransport ?? "remote"; +} + +export function getBackendBaseUrl(backend: BackendBaseUrlInput): string { + if ( + backend.kind === "agent-server" && + backend.agentServerTransport === "same-origin" && + typeof window !== "undefined" + ) { + return window.location.origin; + } + + return backend.host; } export interface BackendSelection { diff --git a/src/api/cloud/proxy.ts b/src/api/cloud/proxy.ts index d21e2c0e2..f1af55333 100644 --- a/src/api/cloud/proxy.ts +++ b/src/api/cloud/proxy.ts @@ -3,15 +3,14 @@ import { getActiveBackend, getEffectiveLocalBackend, } from "../backend-registry/active-store"; -import { getAgentServerHeaders } from "../agent-server-config"; import { buildAuthHeaders } from "../backend-registry/auth"; -import type { Backend } from "../backend-registry/types"; +import { getBackendBaseUrl, type Backend } from "../backend-registry/types"; interface CloudProxyRequest { /** * Cloud backend whose bearer token authenticates the upstream call. - * `backend.host` is also the default upstream host unless `hostOverride` - * is set. + * The backend's resolved base URL is also the default upstream host unless + * `hostOverride` is set. */ backend: Backend; /** HTTP method against the upstream host. */ @@ -26,8 +25,8 @@ interface CloudProxyRequest { timeoutSeconds?: number; /** * Override the upstream host. When set, the proxy targets this host - * instead of `backend.host`. Used for runtime-sandbox calls where the - * upstream lives at the conversation's runtime URL (e.g. + * instead of the backend's resolved base URL. Used for runtime-sandbox calls + * where the upstream lives at the conversation's runtime URL (e.g. * `http://.prod-runtime.all-hands.dev`) rather than the cloud API. * The host must still pass the proxy's allowlist server-side. */ @@ -74,10 +73,7 @@ export async function callCloudProxy( req: CloudProxyRequest, ): Promise { const local = getEffectiveLocalBackend(); - const localAuthHeaders = { - ...buildAuthHeaders(local), - ...getAgentServerHeaders(), - }; + const localAuthHeaders = buildAuthHeaders(local); // Send `X-Org-Id` so the upstream scopes per-request to the org the user // selected locally, instead of the user's globally-shared // `current_org_id` on the cloud backend. Restricted to calls against the active @@ -95,14 +91,15 @@ export async function callCloudProxy( ...orgIdHeader, ...(req.headers ?? {}), }; - const upstreamHost = req.hostOverride ?? req.backend.host; + const upstreamHost = req.hostOverride ?? getBackendBaseUrl(req.backend); + const localHost = getBackendBaseUrl(local); // Talk directly to the local agent-server, bypassing the global // local agent-server client configuration (which would otherwise read host + auth // from the active backend — wrong for this call: we need the local // backend's host and session key explicitly, not the active one). const response = await axios.post( - `${local.host.replace(/\/+$/, "")}/api/cloud-proxy`, + `${localHost.replace(/\/+$/, "")}/api/cloud-proxy`, { host: upstreamHost, method: req.method, diff --git a/src/api/conversation-service/agent-server-conversation-service.api.ts b/src/api/conversation-service/agent-server-conversation-service.api.ts index 35ed54814..108f0e105 100644 --- a/src/api/conversation-service/agent-server-conversation-service.api.ts +++ b/src/api/conversation-service/agent-server-conversation-service.api.ts @@ -20,6 +20,7 @@ import { getActiveBackend, getEffectiveLocalBackend, } from "../backend-registry/active-store"; +import { getBackendBaseUrl } from "../backend-registry/types"; import { callCloudProxy } from "../cloud/proxy"; import { batchGetCloudConversations, @@ -419,7 +420,7 @@ class AgentServerConversationService { status: "READY", detail: null, app_conversation_id: data.id, - agent_server_url: getEffectiveLocalBackend().host, + agent_server_url: getBackendBaseUrl(getEffectiveLocalBackend()), request: { initial_message: payload.initial_message as | AppConversationStartRequest["initial_message"] diff --git a/src/api/device-flow-client.ts b/src/api/device-flow-client.ts index 7a777ad00..f1285251a 100644 --- a/src/api/device-flow-client.ts +++ b/src/api/device-flow-client.ts @@ -12,6 +12,7 @@ import { getEffectiveLocalBackend } from "./backend-registry/active-store"; import { buildAuthHeaders } from "./backend-registry/auth"; +import { getBackendBaseUrl } from "./backend-registry/types"; export class DeviceFlowError extends Error { constructor( @@ -86,7 +87,7 @@ async function makeProxiedRequest( signal?: AbortSignal, ): Promise { const local = getEffectiveLocalBackend(); - const proxyUrl = `${local.host.replace(/\/+$/, "")}/api/cloud-proxy`; + const proxyUrl = `${getBackendBaseUrl(local).replace(/\/+$/, "")}/api/cloud-proxy`; const response = await fetch(proxyUrl, { method: "POST", diff --git a/src/api/option-service/option-service.api.ts b/src/api/option-service/option-service.api.ts index 80ee208a6..8a6bf5f7d 100644 --- a/src/api/option-service/option-service.api.ts +++ b/src/api/option-service/option-service.api.ts @@ -1,5 +1,5 @@ import { LLMMetadataClient } from "@openhands/typescript-client/clients"; -import { loadAgentServerInfo } from "../agent-server-compatibility"; +import { preflightAgentServerAccess } from "../agent-server-compatibility"; import { getAgentServerClientOptions } from "../agent-server-client-options"; import { ModelsResponse, WebClientConfig } from "./option.types"; @@ -28,7 +28,7 @@ class OptionService { } static async getConfig(): Promise { - await loadAgentServerInfo(); + await preflightAgentServerAccess(); return { posthog_client_key: null, diff --git a/src/components/features/automations/recommended-automations-launcher.tsx b/src/components/features/automations/recommended-automations-launcher.tsx index 5ab90710b..893f48634 100644 --- a/src/components/features/automations/recommended-automations-launcher.tsx +++ b/src/components/features/automations/recommended-automations-launcher.tsx @@ -47,7 +47,7 @@ function trimTrailingSlashes(value: string): string { export function buildAutomationPrompt( basePrompt: string, - backendKind: "local" | "cloud", + backendKind: "agent-server" | "cloud", backendHost?: string, ): string { if (backendKind === "cloud") { diff --git a/src/components/features/automations/recommended-automations-section.tsx b/src/components/features/automations/recommended-automations-section.tsx index a64833b6e..59d8ce407 100644 --- a/src/components/features/automations/recommended-automations-section.tsx +++ b/src/components/features/automations/recommended-automations-section.tsx @@ -35,7 +35,7 @@ import ClockIcon from "#/icons/clock.svg?react"; import { StatusBadge } from "./status-badge"; interface RecommendedAutomationsSectionProps { - backendKind: "local" | "cloud"; + backendKind: "agent-server" | "cloud"; installedServers: MCPServerConfig[]; query?: string; onSelect: (automation: RecommendedAutomation) => void; @@ -85,7 +85,7 @@ function automationMatchesQuery( function isAutomationAvailable( automation: RecommendedAutomation, - backendKind: "local" | "cloud", + backendKind: "agent-server" | "cloud", ) { return getRequiredEntries(automation).every((entry) => isMarketplaceEntryAvailable(entry, backendKind), diff --git a/src/components/features/backends/add-backend-modal.tsx b/src/components/features/backends/add-backend-modal.tsx index ecacf9696..d23aa13bf 100644 --- a/src/components/features/backends/add-backend-modal.tsx +++ b/src/components/features/backends/add-backend-modal.tsx @@ -2,8 +2,18 @@ import { BackendFormModal } from "./backend-form-modal"; interface AddBackendModalProps { onClose: () => void; + showCloseButton?: boolean; } -export function AddBackendModal({ onClose }: AddBackendModalProps) { - return ; +export function AddBackendModal({ + onClose, + showCloseButton, +}: AddBackendModalProps) { + return ( + + ); } diff --git a/src/components/features/backends/backend-form-modal.tsx b/src/components/features/backends/backend-form-modal.tsx index 1bcdfb175..7a9adbf1a 100644 --- a/src/components/features/backends/backend-form-modal.tsx +++ b/src/components/features/backends/backend-form-modal.tsx @@ -1,6 +1,7 @@ import React from "react"; import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; import { ServerClient } from "@openhands/typescript-client/clients"; import OpenHandsLogoWhite from "#/assets/branding/openhands-logo-white.svg?react"; import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; @@ -15,9 +16,15 @@ import { useActiveBackendContext } from "#/contexts/active-backend-context"; import { useNavigation } from "#/context/navigation-context"; import { useBackendsHealth } from "#/hooks/query/use-backends-health"; import { getAgentServerClientOptions } from "#/api/agent-server-client-options"; +import { getBackendSessionApiKey } from "#/api/backend-registry/auth"; import ChevronDownSmallIcon from "#/icons/chevron-down-small.svg?react"; import { I18nKey } from "#/i18n/declaration"; -import type { Backend, BackendKind } from "#/api/backend-registry/types"; +import { + getBackendBaseUrl, + getBackendConnectionKind, + 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"; @@ -29,6 +36,7 @@ interface BackendFormModalProps { /** Required when `mode === "edit"`. */ backend?: Backend; onClose: () => void; + showCloseButton?: boolean; } function inferKindFromHost(host: string): BackendKind { @@ -36,7 +44,21 @@ function inferKindFromHost(host: string): BackendKind { if (trimmed.includes("all-hands.dev") || trimmed.includes("openhands.dev")) { return "cloud"; } - return "local"; + return "agent-server"; +} + +export function getBackendConnectionLabel( + backend: Backend, + t: TFunction, +): string { + switch (getBackendConnectionKind(backend)) { + case "same-origin": + return t(I18nKey.BACKEND$TRANSPORT_SAME_ORIGIN); + case "remote": + return t(I18nKey.BACKEND$TRANSPORT_REMOTE); + case "cloud": + return t(I18nKey.BACKEND$KIND_CLOUD); + } } /** @@ -113,11 +135,19 @@ function isValidHostUrl(host: string): boolean { } const DEFAULT_OPENHANDS_CLOUD_HOST = "https://app.all-hands.dev"; +const MANUAL_BACKEND_PROBE_TIMEOUT_MS = 5000; +const BACKEND_HOST_INTERPOLATION_SENTINEL = "__BACKEND_HOST__"; + +function getConnectionTestFailedMessage(t: TFunction, host: string) { + return t(I18nKey.BACKEND$CONNECTION_TEST_FAILED, { + host: BACKEND_HOST_INTERPOLATION_SENTINEL, + }).replace(BACKEND_HOST_INTERPOLATION_SENTINEL, host); +} /** * Live status row for the edit form: shows a connection dot, a - * "Local"/"Cloud" label, and the agent server's reported version when - * available. Replaces the legacy local/cloud radio fieldset (kind is + * backend connection label, and the agent server's reported version when + * available. Replaces the legacy agent-server/cloud radio fieldset (kind is * now inferred from the host). */ function BackendStatusBadge({ @@ -128,6 +158,8 @@ function BackendStatusBadge({ testIdRoot: string; }) { const { t } = useTranslation("openhands"); + const backendBaseUrl = getBackendBaseUrl(backend); + const backendSessionApiKey = getBackendSessionApiKey(backend); const healthByBackendId = useBackendsHealth([backend]); const health = healthByBackendId[backend.id]; const isConnected = health?.isConnected ?? null; @@ -136,12 +168,12 @@ function BackendStatusBadge({ const lastError = health?.lastError ?? null; const { data: version } = useQuery({ - queryKey: ["backend-version", backend.host, backend.apiKey], + queryKey: ["backend-version", backendBaseUrl, backendSessionApiKey], queryFn: async () => { const info = await new ServerClient( getAgentServerClientOptions({ - host: backend.host, - sessionApiKey: backend.apiKey || null, + host: backendBaseUrl, + sessionApiKey: backendSessionApiKey, timeout: 5000, }), ).getServerInfo(); @@ -149,7 +181,8 @@ function BackendStatusBadge({ }, retry: false, staleTime: 60_000, - enabled: backend.kind === "local" && !disabled, + enabled: backend.kind === "agent-server" && !disabled, + meta: { disableToast: true }, }); let statusLabel: string; @@ -161,10 +194,7 @@ function BackendStatusBadge({ statusLabel = t(I18nKey.ONBOARDING$BACKEND_STATUS_CHECKING); } - const kindLabel = - backend.kind === "cloud" - ? t(I18nKey.BACKEND$KIND_CLOUD) - : t(I18nKey.BACKEND$KIND_LOCAL); + const kindLabel = getBackendConnectionLabel(backend, t); return (
@@ -260,8 +290,9 @@ export function BackendForm({ const { t } = useTranslation("openhands"); const { addBackend, updateBackend } = useActiveBackendContext(); + const originalHost = backend ? getBackendBaseUrl(backend) : ""; const [name, setName] = React.useState(backend?.name ?? ""); - const [host, setHost] = React.useState(backend?.host ?? ""); + const [host, setHost] = React.useState(originalHost); const [apiKey, setApiKey] = React.useState(backend?.apiKey ?? ""); // Inline validation: only show errors after the user has left a field. @@ -277,7 +308,7 @@ export function BackendForm({ const canSubmit = name.trim().length > 0 && isValidHostUrl(host) && - (kind === "local" || apiKey.trim().length > 0); + (kind === "agent-server" || apiKey.trim().length > 0); // Error messages — only surfaced after the user has blurred the field. const nameError = @@ -300,11 +331,21 @@ export function BackendForm({ return; } + const normalizedHost = normalizeHost(host); + const hostChanged = + mode === "edit" && backend ? normalizedHost !== originalHost : false; + const agentServerTransport: Backend["agentServerTransport"] = + kind === "agent-server" + ? backend?.agentServerTransport === "same-origin" && !hostChanged + ? "same-origin" + : "remote" + : undefined; const payload = { name: name.trim(), - host: normalizeHost(host), + host: normalizedHost, apiKey: apiKey.trim(), kind, + agentServerTransport, }; if (mode === "edit" && backend) { @@ -421,21 +462,61 @@ function ManualConnectionColumn({ onClose }: { onClose: () => void }) { const [name, setName] = React.useState(""); const [host, setHost] = React.useState(""); const [apiKey, setApiKey] = React.useState(""); + const [isConnecting, setIsConnecting] = React.useState(false); + const [connectionError, setConnectionError] = React.useState<{ + title: string; + detail: string | null; + } | null>(null); const kind: BackendKind = inferKindFromHost(host); const canSubmit = name.trim().length > 0 && isValidHostUrl(host) && - (kind === "local" || apiKey.trim().length > 0); + (kind === "agent-server" || apiKey.trim().length > 0); + + const clearConnectionError = React.useCallback(() => { + setConnectionError(null); + }, []); - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!canSubmit) return; + setConnectionError(null); + + const normalizedHost = normalizeHost(host); + const trimmedApiKey = apiKey.trim(); + + if (kind === "agent-server") { + setIsConnecting(true); + try { + await new ServerClient( + getAgentServerClientOptions({ + host: normalizedHost, + sessionApiKey: trimmedApiKey || null, + timeout: MANUAL_BACKEND_PROBE_TIMEOUT_MS, + }), + ).getServerInfo(); + } catch (error) { + const detail = + error instanceof Error && error.message.trim() + ? error.message.trim() + : null; + setConnectionError({ + title: getConnectionTestFailedMessage(t, normalizedHost), + detail, + }); + setIsConnecting(false); + return; + } + setIsConnecting(false); + } + addBackend({ name: name.trim(), - host: normalizeHost(host), - apiKey: apiKey.trim(), + host: normalizedHost, + apiKey: trimmedApiKey, kind, + agentServerTransport: kind === "agent-server" ? "remote" : undefined, }); redirectAfterAdd(); onClose(); @@ -454,7 +535,10 @@ function ManualConnectionColumn({ onClose }: { onClose: () => void }) { type="text" label={t(I18nKey.BACKEND$NAME_LABEL)} value={name} - onChange={setName} + onChange={(value) => { + setName(value); + clearConnectionError(); + }} placeholder="e.g. My Server" className="w-full" /> @@ -470,7 +554,10 @@ function ManualConnectionColumn({ onClose }: { onClose: () => void }) { type="text" label={t(I18nKey.BACKEND$HOST_LABEL)} value={host} - onChange={setHost} + onChange={(value) => { + setHost(value); + clearConnectionError(); + }} placeholder="http://localhost:8000" className="w-full" /> @@ -488,19 +575,42 @@ function ManualConnectionColumn({ onClose }: { onClose: () => void }) { type="password" label={t(I18nKey.BACKEND$KEY_LABEL)} value={apiKey} - onChange={setApiKey} + onChange={(value) => { + setApiKey(value); + clearConnectionError(); + }} placeholder="sk-••••••••••" className="w-full" /> + {connectionError ? ( +
+

{connectionError.title}

+ {connectionError.detail ? ( +

+ {connectionError.detail} +

+ ) : null} +
+ ) : null} + - {t(I18nKey.BACKEND$CONNECT)} + {isConnecting + ? t(I18nKey.ONBOARDING$BACKEND_STATUS_CHECKING) + : t(I18nKey.BACKEND$CONNECT)} ); @@ -609,13 +719,14 @@ export function BackendFormModal({ mode, backend, onClose, + showCloseButton = true, }: BackendFormModalProps) { const { t } = useTranslation("openhands"); if (mode === "add") { return ( @@ -627,7 +738,9 @@ export function BackendFormModal({ MODAL_MAX_WIDTH_VIEWPORT, )} > - + {showCloseButton ? ( + + ) : null} {/* Header */}

diff --git a/src/components/features/backends/backend-selector.tsx b/src/components/features/backends/backend-selector.tsx index 01504a29e..cb85c1011 100644 --- a/src/components/features/backends/backend-selector.tsx +++ b/src/components/features/backends/backend-selector.tsx @@ -57,7 +57,7 @@ function buildOptions( ): DropdownOption[] { const options: DropdownOption[] = []; - const locals = registered.filter((b) => b.kind === "local"); + const locals = registered.filter((b) => b.kind === "agent-server"); const clouds = registered.filter((b) => b.kind === "cloud"); for (const b of locals) { diff --git a/src/components/features/backends/manage-backends-modal.tsx b/src/components/features/backends/manage-backends-modal.tsx index 29e5aafef..7808a24f0 100644 --- a/src/components/features/backends/manage-backends-modal.tsx +++ b/src/components/features/backends/manage-backends-modal.tsx @@ -4,8 +4,9 @@ import { useQuery } from "@tanstack/react-query"; import { Pencil, Plus, Trash2 } from "lucide-react"; import { ServerClient } from "@openhands/typescript-client/clients"; -import { type Backend } from "#/api/backend-registry/types"; +import { getBackendBaseUrl, type Backend } from "#/api/backend-registry/types"; import { getAgentServerClientOptions } from "#/api/agent-server-client-options"; +import { getBackendSessionApiKey } from "#/api/backend-registry/auth"; import { BrandButton } from "#/components/features/settings/brand-button"; import { ConfirmationModal } from "#/components/shared/modals/confirmation-modal"; import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; @@ -21,7 +22,10 @@ import { } from "#/hooks/query/use-backends-health"; import { I18nKey } from "#/i18n/declaration"; import { cn } from "#/utils/utils"; -import { BackendFormModal } from "./backend-form-modal"; +import { + BackendFormModal, + getBackendConnectionLabel, +} from "./backend-form-modal"; import { BackendStatusDot } from "./backend-status-dot"; const ROW_ACTION_BUTTON_CLASS = @@ -29,13 +33,15 @@ const ROW_ACTION_BUTTON_CLASS = function BackendVersion({ backend }: { backend: Backend }) { const { t } = useTranslation("openhands"); + const backendBaseUrl = getBackendBaseUrl(backend); + const backendSessionApiKey = getBackendSessionApiKey(backend); const { data: version } = useQuery({ - queryKey: ["backend-version", backend.host, backend.apiKey], + queryKey: ["backend-version", backendBaseUrl, backendSessionApiKey], queryFn: async () => { const info = await new ServerClient( getAgentServerClientOptions({ - host: backend.host, - sessionApiKey: backend.apiKey || null, + host: backendBaseUrl, + sessionApiKey: backendSessionApiKey, timeout: 5000, }), ).getServerInfo(); @@ -43,7 +49,8 @@ function BackendVersion({ backend }: { backend: Backend }) { }, retry: false, staleTime: 60_000, - enabled: backend.kind === "local", + enabled: backend.kind === "agent-server", + meta: { disableToast: true }, }); if (!version) return null; @@ -89,13 +96,11 @@ function BackendRow({ backend, health, onEdit, onRemove }: BackendRowProps) {

- {backend.host} + {getBackendBaseUrl(backend)}
- {backend.kind === "cloud" - ? t(I18nKey.BACKEND$KIND_CLOUD) - : t(I18nKey.BACKEND$KIND_LOCAL)} + {getBackendConnectionLabel(backend, t)}