From 9d7ca75a988ff0e286d209e2c0499b89a639a625 Mon Sep 17 00:00:00 2001 From: Graham Neubig Date: Fri, 29 May 2026 13:43:20 -0700 Subject: [PATCH 01/13] fix: improve frontend backend setup flow - avoid seeding a backend for frontend-only dev launches - route missing or unauthorized agent server preflight to backend settings - surface CORS failures with localized agent-server launch guidance - keep create-conversation CORS errors to a single toast --- DEVELOPMENT.md | 12 +- __tests__/api/agent-server-config.test.ts | 12 + .../api/backend-registry/storage.test.ts | 57 +++- __tests__/api/option-service.test.ts | 74 ++++- .../backends/backend-selector.test.tsx | 2 + .../backends/manage-backends-modal.test.tsx | 67 ++++- .../features/home/home-chat-launcher.test.tsx | 33 ++- .../home/workspace-selection-form.test.tsx | 37 ++- .../mutation/use-create-conversation.test.tsx | 103 ++++++- __tests__/root.test.tsx | 217 ++++++++++++-- __tests__/vite-config.test.ts | 59 +++- src/api/agent-server-compatibility.ts | 126 +++++++- src/api/agent-server-config.ts | 8 + src/api/backend-registry/active-store.ts | 8 + src/api/backend-registry/storage.ts | 79 +++-- src/api/option-service/option-service.api.ts | 4 +- .../features/backends/backend-form-modal.tsx | 1 + .../backends/manage-backends-modal.tsx | 164 +++++----- .../features/home/home-chat-launcher.tsx | 3 +- .../settings/agent-server-onboarding.tsx | 238 ++++++--------- src/contexts/active-backend-context.tsx | 31 +- src/hooks/mutation/use-create-conversation.ts | 14 +- src/hooks/query/query-keys.test.ts | 21 +- src/hooks/query/query-keys.ts | 19 +- src/hooks/query/use-config.ts | 10 +- src/hooks/query/use-resolved-workspaces.ts | 33 ++- src/i18n/translation.json | 279 ++++++++++++------ src/root.tsx | 49 ++- src/routes.ts | 1 + src/routes/backend-settings.tsx | 7 + src/routes/settings.tsx | 5 +- src/utils/agent-server-cors-error.ts | 66 +++++ vite.config.ts | 111 +++---- 33 files changed, 1435 insertions(+), 515 deletions(-) create mode 100644 src/routes/backend-settings.tsx create mode 100644 src/utils/agent-server-cors-error.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a68e03de9..0247aeba2 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -84,10 +84,18 @@ Use this only if you intentionally started `agent-server` yourself or want the f npm run dev:frontend ``` -The frontend-only workflow expects the backend at `127.0.0.1:8000` by default. +The frontend-only workflow does not preconfigure a backend. On first load, the +app opens the backend manager so you can add one from the UI without restarting +the frontend. If you start the backend with `SESSION_API_KEY` or `OH_SESSION_API_KEYS_0`, every `/api/*` route is authenticated with `X-Session-API-Key`. In that case the frontend must send the same key via `VITE_SESSION_API_KEY`. +Set `VITE_BACKEND_BASE_URL` when you want the first default backend to be +preconfigured. Set `VITE_BACKEND_HOST` when you also want the Vite dev server +to proxy same-origin `/api`, `/server_info`, and `/sockets` requests to that +backend. Backends added through the UI are stored in local browser storage and +can be switched from the backend selector. + ### Mock mode If you want to run the frontend without a live backend, use: @@ -145,7 +153,7 @@ You can create a `.env` file in the project directory with these variables based | Variable | Description | Default Value | | --------------------------- | ---------------------------------------------------------------------------------- | ---------------------- | | `VITE_BACKEND_BASE_URL` | Full base URL for the agent server used by direct browser requests | current browser origin | -| `VITE_BACKEND_HOST` | Backend host used by the Vite dev proxy | `127.0.0.1:8000` | +| `VITE_BACKEND_HOST` | Backend host used by the Vite dev proxy when set | - | | `VITE_SESSION_API_KEY` | Optional `X-Session-API-Key` header value for authenticated agent_server instances | - | | `VITE_WORKING_DIR` | Workspace path sent when starting new conversations | `workspace/project` | | `VITE_WORKER_URLS` | Optional comma-separated worker/app URLs for the Browser tab | - | diff --git a/__tests__/api/agent-server-config.test.ts b/__tests__/api/agent-server-config.test.ts index d2fdd6b28..a363d6cfa 100644 --- a/__tests__/api/agent-server-config.test.ts +++ b/__tests__/api/agent-server-config.test.ts @@ -30,6 +30,18 @@ afterEach(() => { }); describe("agent server config", () => { + it("falls back to the browser origin when no explicit backend URL is configured", () => { + mockWindowLocation("https://canvas.example.dev/settings"); + + expect(getAgentServerBaseUrl()).toBe("https://canvas.example.dev"); + }); + + it("uses the backend base URL when explicitly configured", () => { + vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000"); + + expect(getAgentServerBaseUrl()).toBe("http://127.0.0.1:8000"); + }); + it("uses the browser origin when a remote browser is pointed at localhost backend config", () => { mockWindowLocation("https://work-1.example.dev/settings"); window.localStorage.setItem( diff --git a/__tests__/api/backend-registry/storage.test.ts b/__tests__/api/backend-registry/storage.test.ts index 25aa64377..6eded10d6 100644 --- a/__tests__/api/backend-registry/storage.test.ts +++ b/__tests__/api/backend-registry/storage.test.ts @@ -9,6 +9,10 @@ import { } from "#/api/backend-registry/storage"; import type { Backend } from "#/api/backend-registry/types"; +function stubBackendDefaults() { + vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000"); +} + afterEach(() => { window.localStorage.clear(); vi.unstubAllEnvs(); @@ -43,7 +47,23 @@ describe("backend-registry storage", () => { expect(readStoredBackends()).toEqual([]); }); - it("seeds the default Local backend when storage key is missing", () => { + it("does not seed a default Local backend when no backend defaults are configured", () => { + expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).toBeNull(); + + expect(readStoredBackends()).toEqual([]); + expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).toBeNull(); + }); + + it("does not seed a default Local backend from a session key alone", () => { + vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key"); + expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).toBeNull(); + + expect(readStoredBackends()).toEqual([]); + expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).toBeNull(); + }); + + it("seeds the default Local backend when storage key is missing and backend defaults are configured", () => { + stubBackendDefaults(); expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).toBeNull(); const result = readStoredBackends(); @@ -56,6 +76,7 @@ describe("backend-registry storage", () => { }); it("re-seeds the default Local backend when storage holds an empty array", () => { + stubBackendDefaults(); window.localStorage.setItem(BACKENDS_STORAGE_KEY, JSON.stringify([])); const result = readStoredBackends(); @@ -65,6 +86,7 @@ describe("backend-registry storage", () => { }); it("re-seeds the default Local backend when every stored entry is invalid", () => { + stubBackendDefaults(); window.localStorage.setItem( BACKENDS_STORAGE_KEY, JSON.stringify([{ kind: "cloud" }, "not-an-object"]), @@ -76,6 +98,37 @@ describe("backend-registry storage", () => { expect(result[0]).toMatchObject({ id: "default-local", kind: "local" }); }); + it("removes a stale auto-seeded default Local backend when backend defaults are no longer configured", () => { + window.localStorage.setItem( + BACKENDS_STORAGE_KEY, + JSON.stringify([ + { + id: "default-local", + name: "Local", + host: "http://127.0.0.1:8000", + apiKey: "", + kind: "local", + }, + ]), + ); + + expect(readStoredBackends()).toEqual([]); + expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).toBe("[]"); + }); + + it("keeps a stored default Local backend with a session key when backend defaults are no longer configured", () => { + const backend = { + id: "default-local", + name: "Local", + host: "http://127.0.0.1:8000", + apiKey: "existing-session-key", + kind: "local", + }; + window.localStorage.setItem(BACKENDS_STORAGE_KEY, JSON.stringify([backend])); + + expect(readStoredBackends()).toEqual([backend]); + }); + it("filters out backends with invalid shape", () => { window.localStorage.setItem( BACKENDS_STORAGE_KEY, @@ -93,6 +146,7 @@ describe("backend-registry storage", () => { }); it("fills a missing API key on the default Local backend from env defaults", () => { + stubBackendDefaults(); vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key"); window.localStorage.setItem( BACKENDS_STORAGE_KEY, @@ -123,6 +177,7 @@ describe("backend-registry storage", () => { it("refreshes a stale API key on the default Local backend from env defaults", () => { + stubBackendDefaults(); vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key"); window.localStorage.setItem( BACKENDS_STORAGE_KEY, diff --git a/__tests__/api/option-service.test.ts b/__tests__/api/option-service.test.ts index 0a9e60459..1b3885219 100644 --- a/__tests__/api/option-service.test.ts +++ b/__tests__/api/option-service.test.ts @@ -1,5 +1,12 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { http, HttpResponse } from "msw"; +import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store"; +import { + writeStoredActiveBackend, + writeStoredBackends, +} from "#/api/backend-registry/storage"; +import i18n, { OPENHANDS_I18N_NAMESPACE } from "#/i18n"; +import { I18nKey } from "#/i18n/declaration"; import { AgentServerUnavailableError, clearCachedAgentServerInfo, @@ -8,12 +15,45 @@ import { import OptionService from "#/api/option-service/option-service.api"; import { server } from "#/mocks/node"; +const TEST_BACKEND = { + id: "test-local-backend", + name: "Test local backend", + host: "http://127.0.0.1:8000", + apiKey: "", + kind: "local" as const, +}; + describe("OptionService", () => { beforeEach(() => { clearCachedAgentServerInfo(); + window.localStorage.clear(); + writeStoredBackends([TEST_BACKEND]); + writeStoredActiveBackend({ backendId: TEST_BACKEND.id }); + __resetActiveStoreForTests(); + i18n.addResourceBundle( + "en", + OPENHANDS_I18N_NAMESPACE, + { + [I18nKey.ERROR$AGENT_SERVER_CORS]: + "Restart `agent-server` with `OH_ALLOW_CORS_ORIGINS='[\"{{frontendOrigin}}\"]'`.", + }, + true, + true, + ); + i18n.changeLanguage("en"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + window.localStorage.clear(); + __resetActiveStoreForTests(); }); it("returns config in mock mode without a live backend", async () => { + vi.stubEnv("VITE_MOCK_API", "true"); + window.localStorage.clear(); + __resetActiveStoreForTests(); + const config = await OptionService.getConfig(); expect(config.feature_flags.hide_llm_settings).toBe(false); @@ -51,7 +91,37 @@ describe("OptionService", () => { await expect(OptionService.getConfig()).rejects.toMatchObject({ name: AgentServerUnavailableError.name, message: expect.stringContaining("Agent server not found"), - details: expect.stringContaining("Request failed"), + details: expect.stringContaining("OH_ALLOW_CORS_ORIGINS"), + reason: "unreachable", + }); + }); + + it("throws an unavailable error when the agent server rejects the session key", async () => { + server.use( + http.get("*/server_info", () => new HttpResponse(null, { status: 401 })), + ); + + await expect(OptionService.getConfig()).rejects.toMatchObject({ + name: AgentServerUnavailableError.name, + message: expect.stringContaining("Agent server not found"), + reason: "unauthorized", + status: 401, + }); + }); + + it("throws an unavailable error when protected API auth fails after server_info succeeds", async () => { + server.use( + http.get("*/server_info", () => + HttpResponse.json({ uptime: 0, idle_time: 0, version: "1.24.0" }), + ), + http.get("*/api/settings", () => new HttpResponse(null, { status: 401 })), + ); + + await expect(OptionService.getConfig()).rejects.toMatchObject({ + name: AgentServerUnavailableError.name, + message: expect.stringContaining("Agent server not found"), + reason: "unauthorized", + status: 401, }); }); diff --git a/__tests__/components/backends/backend-selector.test.tsx b/__tests__/components/backends/backend-selector.test.tsx index b53085e0c..23e7998ef 100644 --- a/__tests__/components/backends/backend-selector.test.tsx +++ b/__tests__/components/backends/backend-selector.test.tsx @@ -95,6 +95,7 @@ async function openDropdown() { beforeEach(() => { window.localStorage.clear(); + vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:3000"); __resetActiveStoreForTests(); vi.mocked(getCloudOrganizations).mockReset(); vi.mocked(getCloudOrganizationMe).mockReset(); @@ -140,6 +141,7 @@ afterEach(async () => { }); } window.localStorage.clear(); + vi.unstubAllEnvs(); __resetActiveStoreForTests(); __resetEnvironmentSwitchOverlayForTests(); }); diff --git a/__tests__/components/backends/manage-backends-modal.test.tsx b/__tests__/components/backends/manage-backends-modal.test.tsx index 62d8f69ce..d1a3861f7 100644 --- a/__tests__/components/backends/manage-backends-modal.test.tsx +++ b/__tests__/components/backends/manage-backends-modal.test.tsx @@ -15,6 +15,8 @@ import { ActiveBackendProvider, useActiveBackendContext, } from "#/contexts/active-backend-context"; +import { createAgentServerQueryClient } from "#/query-client-config"; +import * as ToastHandlers from "#/utils/custom-toast-handlers"; import { ManageBackendsModal } from "#/components/features/backends/manage-backends-modal"; const getServerInfoMock = vi.fn().mockResolvedValue({ version: "1.18.0" }); @@ -43,6 +45,14 @@ function renderWithProviders(ui: React.ReactElement) { ); } +function renderWithAgentQueryClient(ui: React.ReactElement) { + return render( + + {ui} + , + ); +} + function TestSeed({ onMount, children, @@ -60,11 +70,16 @@ function TestSeed({ beforeEach(() => { window.localStorage.clear(); + vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:3000"); + getServerInfoMock.mockReset(); + getServerInfoMock.mockResolvedValue({ version: "1.18.0" }); __resetActiveStoreForTests(); __resetHealthStoreForTests(); }); afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); window.localStorage.clear(); __resetActiveStoreForTests(); __resetHealthStoreForTests(); @@ -84,14 +99,30 @@ describe("ManageBackendsModal", () => { expect(dots.length).toBeGreaterThanOrEqual(1); }); + it("does not show a global toast when a backend version probe is unauthorized", async () => { + const toastSpy = vi.spyOn(ToastHandlers, "displayErrorToast"); + getServerInfoMock.mockRejectedValue( + Object.assign(new Error("Unauthorized"), { + name: "HttpError", + status: 401, + }), + ); + + renderWithAgentQueryClient(); + + await waitFor(() => { + expect(getServerInfoMock).toHaveBeenCalled(); + }); + + expect(toastSpy).not.toHaveBeenCalled(); + }); + it("closes when the header close button is clicked", async () => { const user = userEvent.setup(); const onClose = vi.fn(); renderWithProviders(); - await user.click( - await screen.findByTestId("close-manage-backends-modal"), - ); + await user.click(await screen.findByTestId("close-manage-backends-modal")); expect(onClose).toHaveBeenCalledTimes(1); }); @@ -178,6 +209,36 @@ describe("ManageBackendsModal", () => { expect(backendId).not.toBe(""); }); + it("syncs default local backend edits to the legacy agent-server config", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(await screen.findByTestId("manage-backends-edit-Local")); + + const hostInput = await screen.findByTestId("edit-backend-host"); + await user.clear(hostInput); + await user.type(hostInput, "http://localhost:18100"); + + const keyInput = screen.getByTestId("edit-backend-api-key"); + await user.clear(keyInput); + await user.type(keyInput, "new-session-key"); + + await user.click(screen.getByTestId("edit-backend-submit")); + + await waitFor(() => { + expect( + screen.queryByTestId("edit-backend-modal"), + ).not.toBeInTheDocument(); + }); + + expect( + window.localStorage.getItem("openhands-agent-server-config"), + ).toContain("http://localhost:18100"); + expect( + window.localStorage.getItem("openhands-agent-server-config"), + ).toContain("new-session-key"); + }); + it("closes the edit form when the header close button is clicked", async () => { const user = userEvent.setup(); diff --git a/__tests__/components/features/home/home-chat-launcher.test.tsx b/__tests__/components/features/home/home-chat-launcher.test.tsx index 63c3e0f21..e95a7def7 100644 --- a/__tests__/components/features/home/home-chat-launcher.test.tsx +++ b/__tests__/components/features/home/home-chat-launcher.test.tsx @@ -1,12 +1,13 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClientProvider } from "@tanstack/react-query"; import toast from "react-hot-toast"; import { HomeChatLauncher } from "#/components/features/home/home-chat-launcher"; import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; import WorkspacesService from "#/api/workspaces-service/workspaces-service.api"; +import { createAgentServerQueryClient } from "#/query-client-config"; const mockNavigate = vi.fn(); const mockUseActiveBackend = vi.fn(); @@ -176,20 +177,17 @@ vi.mock("#/components/features/home/home-git-control-bar-preview", () => ({ const renderLauncher = () => render(, { - wrapper: ({ children }) => ( - - {children} - - ), + wrapper: ({ children }) => { + const client = createAgentServerQueryClient(); + client.setDefaultOptions({ + queries: { retry: false }, + mutations: { retry: false }, + }); + + return ( + {children} + ); + }, }); function makeConversationResponse( @@ -406,7 +404,10 @@ describe("HomeChatLauncher", () => { const user = userEvent.setup(); await user.click(screen.getByTestId("stub-chat-submit")); - await waitFor(() => expect(mockDisplayErrorToast).toHaveBeenCalled()); + await waitFor(() => + expect(mockDisplayErrorToast).toHaveBeenCalledWith("Network down"), + ); + expect(mockDisplayErrorToast).toHaveBeenCalledTimes(1); expect(mockNavigate).not.toHaveBeenCalled(); }); diff --git a/__tests__/components/features/home/workspace-selection-form.test.tsx b/__tests__/components/features/home/workspace-selection-form.test.tsx index 19b643f54..b5664d7c0 100644 --- a/__tests__/components/features/home/workspace-selection-form.test.tsx +++ b/__tests__/components/features/home/workspace-selection-form.test.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, vi, beforeEach, it } from "vitest"; +import { afterEach, describe, expect, vi, beforeEach, it } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { WorkspaceSelectionForm } from "../../../../src/components/features/home/workspace-selection-form"; @@ -92,14 +92,45 @@ describe("WorkspaceSelectionForm (server-backed workspaces)", () => { mockGetHome.mockReset(); mockUseIsCreatingConversation.mockReturnValue(false); mockGetHome.mockResolvedValue({ home: "/Users/me" }); - // useResolvedWorkspaces always queries an implicit `/projects` parent in - // dev mode — return empty so it doesn't influence tests that don't care. mockSearchSubdirectories.mockResolvedValue({ items: [], next_page_id: null, }); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("does not scan the implicit /projects parent in frontend-only dev", async () => { + renderForm(); + + await waitFor(() => { + expect(WorkspacesService.listWorkspaces).toHaveBeenCalledTimes(1); + }); + + expect(mockSearchSubdirectories).not.toHaveBeenCalledWith("/projects"); + }); + + it("scans the implicit /projects parent when a launcher-managed agent server is advertised", async () => { + vi.stubEnv( + "VITE_RUNTIME_SERVICES_INFO", + JSON.stringify({ + services: { + agent_server: { + url_from_agent: "http://localhost:18000", + }, + }, + }), + ); + + renderForm(); + + await waitFor(() => { + expect(mockSearchSubdirectories).toHaveBeenCalledWith("/projects"); + }); + }); + it("renders workspaces returned by the agent-server in the dropdown", async () => { // Arrange renderForm({ diff --git a/__tests__/hooks/mutation/use-create-conversation.test.tsx b/__tests__/hooks/mutation/use-create-conversation.test.tsx index c65641901..e57bc8ca8 100644 --- a/__tests__/hooks/mutation/use-create-conversation.test.tsx +++ b/__tests__/hooks/mutation/use-create-conversation.test.tsx @@ -1,7 +1,16 @@ +import type React from "react"; import { renderHook, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; +import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store"; +import { + writeStoredActiveBackend, + writeStoredBackends, +} from "#/api/backend-registry/storage"; +import { ActiveBackendProvider } from "#/contexts/active-backend-context"; +import i18n, { OPENHANDS_I18N_NAMESPACE } from "#/i18n"; +import { I18nKey } from "#/i18n/declaration"; import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; import { SuggestedTask } from "#/utils/types"; @@ -11,7 +20,63 @@ vi.mock("#/hooks/use-tracking", () => ({ }), })); +const ORIGINAL_LOCATION = window.location; + +function mockWindowLocation(url: string) { + Object.defineProperty(window, "location", { + configurable: true, + value: new URL(url), + }); +} + +function queryClientWrapper({ + includeActiveBackendProvider = false, +}: { + includeActiveBackendProvider?: boolean; +} = {}) { + const queryClient = new QueryClient(); + + return function Wrapper({ children }: { children: React.ReactNode }) { + const content = includeActiveBackendProvider ? ( + {children} + ) : ( + children + ); + + return ( + {content} + ); + }; +} + describe("useCreateConversation", () => { + beforeEach(() => { + mockWindowLocation("http://127.0.0.1:3001/"); + window.localStorage.clear(); + __resetActiveStoreForTests(); + i18n.addResourceBundle( + "en", + OPENHANDS_I18N_NAMESPACE, + { + [I18nKey.ERROR$AGENT_SERVER_CORS]: + "Agent Canvas could not reach the agent server.\n\nFrontend origin: {{frontendOrigin}}\nBackend: {{backendOrigin}}\n\nRestart `agent-server` with `OH_ALLOW_CORS_ORIGINS='[\"{{frontendOrigin}}\"]'`.", + }, + true, + true, + ); + i18n.changeLanguage("en"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + window.localStorage.clear(); + Object.defineProperty(window, "location", { + configurable: true, + value: ORIGINAL_LOCATION, + }); + __resetActiveStoreForTests(); + }); + it("passes suggested tasks to the V1 create conversation API", async () => { const createConversationSpy = vi .spyOn(AgentServerConversationService, "createConversation") @@ -44,11 +109,7 @@ describe("useCreateConversation", () => { }); const { result } = renderHook(() => useCreateConversation(), { - wrapper: ({ children }) => ( - - {children} - - ), + wrapper: queryClientWrapper(), }); const suggestedTask: SuggestedTask = { @@ -120,9 +181,7 @@ describe("useCreateConversation", () => { const { result } = renderHook(() => useCreateConversation(), { wrapper: ({ children }) => ( - - {children} - + {children} ), }); @@ -137,4 +196,30 @@ describe("useCreateConversation", () => { }); }); }); + + it("turns a cross-origin fetch failure into a readable CORS error", async () => { + const backend = { + id: "local-agent-server", + name: "Local agent server", + host: "http://127.0.0.1:8000", + apiKey: "", + kind: "local" as const, + }; + writeStoredBackends([backend]); + writeStoredActiveBackend({ backendId: backend.id }); + __resetActiveStoreForTests(); + + vi.spyOn( + AgentServerConversationService, + "createConversation", + ).mockRejectedValue(new Error("Request failed: Failed to fetch")); + + const { result } = renderHook(() => useCreateConversation(), { + wrapper: queryClientWrapper({ includeActiveBackendProvider: true }), + }); + + await expect(result.current.mutateAsync({ query: "hello" })).rejects.toThrow( + "Agent Canvas could not reach the agent server.\n\nFrontend origin: http://127.0.0.1:3001\nBackend: http://127.0.0.1:8000\n\nRestart `agent-server` with `OH_ALLOW_CORS_ORIGINS='[\"http://127.0.0.1:3001\"]'`.", + ); + }); }); diff --git a/__tests__/root.test.tsx b/__tests__/root.test.tsx index 49da26945..31c682217 100644 --- a/__tests__/root.test.tsx +++ b/__tests__/root.test.tsx @@ -1,22 +1,53 @@ import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { createRoutesStub } from "react-router"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Navigate, createRoutesStub } from "react-router"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { http, HttpResponse } from "msw"; import App from "#/root"; import { server } from "#/mocks/node"; import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store"; import { ActiveBackendProvider } from "#/contexts/active-backend-context"; +const ORIGINAL_LOCATION = window.location; +const APP_HOME_PATH = "/"; +const BACKEND_SETTINGS_PATH = "/settings/backend"; + const TRANSLATIONS: Record = { - "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", + COMMON$OPTIONAL: "Optional", + SETTINGS$AGENT_SERVER_ONBOARDING_EYEBROW: "Get started", + SETTINGS$AGENT_SERVER_ONBOARDING_TITLE: "Connect to your agent server", + SETTINGS$AGENT_SERVER_ONBOARDING_DESCRIPTION: + "Agent Canvas needs an agent server before it can load conversations, tools, and settings.", + SETTINGS$AGENT_SERVER_MISSING_STATUS_TITLE: "No backend is configured", + SETTINGS$AGENT_SERVER_MISSING_STATUS_MESSAGE: + "Enter the agent server URL and session API key to connect this browser.", + SETTINGS$AGENT_SERVER_AUTH_STATUS_TITLE: "Backend authentication failed", + SETTINGS$AGENT_SERVER_AUTH_STATUS_MESSAGE: + "The configured server rejected the session API key.", + SETTINGS$AGENT_SERVER_UNAVAILABLE_STATUS_TITLE: + "We couldn't reach the configured server", + SETTINGS$AGENT_SERVER_UNAVAILABLE_STATUS_MESSAGE: + "Check the URL, confirm the server is running, and try again.", + SETTINGS$AGENT_SERVER_DETAILS_LABEL: "Details: {{details}}", + SETTINGS$AGENT_SERVER_RETRY_CONNECTION: "Retry connection", + BACKEND$MANAGE_TITLE: "Manage Backends", + BACKEND$MANAGE_EMPTY: "No backends configured.", + BACKEND$ADD: "Add backend", + BACKEND$EDIT: "Edit", + BACKEND$REMOVE: "Remove", + BACKEND$KIND_LOCAL: "Local", + BACKEND$KIND_CLOUD: "Cloud", + BACKEND$VERSION_LABEL: "v{{version}}", + BACKEND$EDIT_TITLE: "Edit backend", + BACKEND$NAME_LABEL: "Name", + BACKEND$HOST_LABEL: "Host", + BACKEND$KEY_LABEL: "API key", + BACKEND$SAVE: "Save", + BUTTON$CANCEL: "Cancel", + ONBOARDING$BACKEND_STATUS_CONNECTED: "Connected", + ONBOARDING$BACKEND_STATUS_DISCONNECTED: "Disconnected", + ONBOARDING$BACKEND_STATUS_CHECKING: "Checking", }; vi.mock("react-i18next", () => ({ @@ -40,6 +71,10 @@ const RouterStub = createRoutesStub([ Component: () =>
app outlet
, path: "/", }, + { + Component: () => , + path: BACKEND_SETTINGS_PATH, + }, ], }, ]); @@ -59,16 +94,29 @@ const renderApp = (initialEntries: string[] = ["/"]) => ), }); +function stubConfiguredBackend(baseUrl = "http://agent.example.com") { + vi.stubEnv("VITE_BACKEND_BASE_URL", baseUrl); + __resetActiveStoreForTests(); +} + describe("App root agent-server availability guard", () => { beforeEach(() => { window.localStorage.clear(); __resetActiveStoreForTests(); }); + afterEach(() => { + vi.unstubAllEnvs(); + Object.defineProperty(window, "location", { + configurable: true, + value: ORIGINAL_LOCATION, + }); + }); it("renders the routed page even when the connected server reports an old version", async () => { + stubConfiguredBackend(); server.use( - http.get("/server_info", () => + http.get("*/server_info", () => HttpResponse.json({ uptime: 0, idle_time: 0, version: "1.0.0" }), ), ); @@ -85,8 +133,9 @@ describe("App root agent-server availability guard", () => { }); it("renders the routed page when the server omits a version field", async () => { + stubConfiguredBackend(); server.use( - http.get("/server_info", () => + http.get("*/server_info", () => HttpResponse.json({ uptime: 0, idle_time: 0 }), ), ); @@ -98,11 +147,9 @@ describe("App root agent-server availability guard", () => { }); }); - it("shows the manage-backends modal when the backend is unreachable", async () => { + it("shows the backend settings page without probing the Vite origin when no backend is configured", async () => { let serverInfoRequests = 0; - // Use "*" prefix to match both relative paths and absolute URLs (e.g., - // http://127.0.0.1:8000/server_info) when VITE_BACKEND_BASE_URL is configured. server.use( http.get("*/server_info", () => { serverInfoRequests += 1; @@ -118,19 +165,145 @@ describe("App root agent-server availability guard", () => { ).toBeInTheDocument(); }); - // The onboarding placeholder now hosts the Manage Backends modal - // directly so the user can edit/add a backend immediately. The - // modal additionally probes /server_info per registered backend - // for its status dot + version label, so the request count is - // bounded but greater than the single config probe. + expect(screen.getByTestId("manage-backends-panel")).toBeInTheDocument(); + expect(screen.getByText("No backend is configured")).toBeInTheDocument(); + expect(screen.getByText("No backends configured.")).toBeInTheDocument(); + expect(screen.queryByText(/^Details:/)).not.toBeInTheDocument(); + expect(serverInfoRequests).toBe(0); + expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument(); + }); + + it("does not dump HTML response bodies into backend error details", async () => { + stubConfiguredBackend(); + server.use( + http.get( + "*/server_info", + () => + new HttpResponse( + '

404 Not Found

', + { + status: 404, + headers: { "Content-Type": "text/html" }, + }, + ), + ), + ); + + renderApp(["/"]); + + await waitFor(() => { + expect( + screen.getByTestId("agent-server-onboarding-screen"), + ).toBeInTheDocument(); + }); + + expect( + screen.getByText("We couldn't reach the configured server"), + ).toBeInTheDocument(); + expect(screen.queryByText(/DOCTYPE html/i)).not.toBeInTheDocument(); + expect( + screen.getByText( + /The server returned an HTML page instead of an agent-server API response\./, + ), + ).toBeInTheDocument(); + }); + + it("redirects to the backend settings page when the backend rejects the session key", async () => { + stubConfiguredBackend(); + server.use( + http.get("*/server_info", () => new HttpResponse(null, { status: 401 })), + ); + + renderApp(["/"]); + await waitFor(() => { - expect(screen.getByTestId("manage-backends-modal")).toBeInTheDocument(); + expect( + screen.getByTestId("agent-server-onboarding-screen"), + ).toBeInTheDocument(); }); - expect(serverInfoRequests).toBeGreaterThanOrEqual(1); + + expect(screen.getByTestId("manage-backends-panel")).toBeInTheDocument(); + expect( + screen.getByText("Backend authentication failed"), + ).toBeInTheDocument(); expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument(); }); + it("redirects to the backend settings page when protected API auth fails", async () => { + stubConfiguredBackend(); + server.use( + http.get("*/server_info", () => + HttpResponse.json({ uptime: 0, idle_time: 0, version: "1.24.0" }), + ), + http.get("*/api/settings", () => new HttpResponse(null, { status: 401 })), + ); + + renderApp(["/"]); + + await waitFor(() => { + expect( + screen.getByTestId("agent-server-onboarding-screen"), + ).toBeInTheDocument(); + }); + + expect( + screen.getByText("Backend authentication failed"), + ).toBeInTheDocument(); + expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument(); + }); + + it("adds backend connection details from the startup fallback", async () => { + const user = userEvent.setup(); + const remoteOrigin = "http://remote-agent.example.com:18000"; + const assign = vi.fn(); + + Object.defineProperty(window, "location", { + configurable: true, + value: Object.assign(new URL("http://localhost/"), { assign }), + }); + __resetActiveStoreForTests(); + + server.use( + http.get("*/server_info", ({ request }) => { + const origin = new URL(request.url).origin; + + if (origin === remoteOrigin) { + return HttpResponse.json({ + uptime: 0, + idle_time: 0, + version: "1.18.0", + }); + } + + return HttpResponse.error(); + }), + ); + + renderApp(["/"]); + + await waitFor(() => { + expect( + screen.getByTestId("agent-server-onboarding-screen"), + ).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId("manage-backends-add")); + await user.type(await screen.findByTestId("add-backend-name"), "Remote"); + const hostInput = await screen.findByTestId("add-backend-host"); + await user.type(hostInput, remoteOrigin); + await user.click(screen.getByTestId("add-backend-submit")); + + expect( + window.localStorage.getItem("openhands-backends"), + ).toContain(remoteOrigin); + await waitFor(() => { + expect(screen.getByTestId("app-outlet")).toBeInTheDocument(); + }); + expect(assign).not.toHaveBeenCalled(); + }); + it("renders the routed page when the agent server is reachable", async () => { + stubConfiguredBackend(); renderApp(["/"]); await waitFor(() => { diff --git a/__tests__/vite-config.test.ts b/__tests__/vite-config.test.ts index daa800abc..610b6a4fd 100644 --- a/__tests__/vite-config.test.ts +++ b/__tests__/vite-config.test.ts @@ -1,9 +1,39 @@ // @vitest-environment node import viteConfig from "../vite.config"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const MANAGED_ENV_KEYS = [ + "BUILD_LIB", + "VITE_BACKEND_BASE_URL", + "VITE_BACKEND_HOST", + "VITE_USE_TLS", + "VITE_FRONTEND_PORT", + "VITE_INSECURE_SKIP_VERIFY", +] as const; +const originalEnv = new Map( + MANAGED_ENV_KEYS.map((key) => [key, process.env[key]]), +); + +function restoreManagedEnv() { + for (const key of MANAGED_ENV_KEYS) { + const originalValue = originalEnv.get(key); + + if (originalValue === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalValue; + } + } +} + +beforeEach(() => { + for (const key of MANAGED_ENV_KEYS) { + delete process.env[key]; + } +}); afterEach(() => { - delete process.env.BUILD_LIB; + restoreManagedEnv(); }); describe("vite optimizeDeps", () => { @@ -66,6 +96,31 @@ describe("vite app build", () => { }); }); +describe("vite backend defaults", () => { + it("does not synthesize backend config or proxy routes for the frontend-only dev server", async () => { + const config = await viteConfig({ mode: "development", command: "serve" }); + + expect(process.env.VITE_BACKEND_BASE_URL).toBeUndefined(); + expect(config.server?.proxy).toBeUndefined(); + }); + + it("uses VITE_BACKEND_HOST for the dev proxy target", async () => { + process.env.VITE_BACKEND_HOST = "127.0.0.1:19000"; + + const config = await viteConfig({ mode: "development", command: "serve" }); + const proxy = config.server?.proxy as Record; + + expect(proxy["/api"]?.target).toBe("http://127.0.0.1:19000/"); + expect(process.env.VITE_BACKEND_BASE_URL).toBeUndefined(); + }); + + it("does not inject a backend base URL for production builds", async () => { + await viteConfig({ mode: "production", command: "build" }); + + expect(process.env.VITE_BACKEND_BASE_URL).toBeUndefined(); + }); +}); + describe("vite library build", () => { it("configures a dual-format preserved-module library build", async () => { process.env.BUILD_LIB = "true"; diff --git a/src/api/agent-server-compatibility.ts b/src/api/agent-server-compatibility.ts index 57d1606b4..8b006c5ca 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 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,11 +81,77 @@ 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 /(?: backend.kind === "local") || + hasConfiguredAgentServerDefaults() + ); +} + export function getRegisteredBackends(): Backend[] { return snapshot.backends; } diff --git a/src/api/backend-registry/storage.ts b/src/api/backend-registry/storage.ts index cb89f9b54..e72d7549f 100644 --- a/src/api/backend-registry/storage.ts +++ b/src/api/backend-registry/storage.ts @@ -1,8 +1,10 @@ import { makeDefaultLocalBackend } from "./default-backend"; +import { hasConfiguredAgentServerDefaults } from "../agent-server-config"; 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_FRONTEND_ONLY_DEV_BACKEND_URL = "http://127.0.0.1:8000"; function isValidKind(value: unknown): value is BackendKind { return value === "local" || value === "cloud"; @@ -29,29 +31,60 @@ function normalizeHostForComparison(host: string): string { } } -function syncDefaultLocalBackendAuth(backend: Backend): Backend { +function isCurrentBrowserOrigin(host: string): boolean { + if (typeof window === "undefined") return false; + return ( + normalizeHostForComparison(host) === + normalizeHostForComparison(window.location.origin) + ); +} + +function syncDefaultLocalBackendConfig(backend: Backend): Backend { const defaultBackend = makeDefaultLocalBackend(); - if ( - backend.id !== defaultBackend.id || - backend.kind !== "local" || - !defaultBackend.apiKey || - normalizeHostForComparison(backend.host) !== - normalizeHostForComparison(defaultBackend.host) - ) { + if (backend.id !== defaultBackend.id || backend.kind !== "local") { return backend; } - if (backend.apiKey === defaultBackend.apiKey) { + const matchesDefaultHost = + normalizeHostForComparison(backend.host) === + normalizeHostForComparison(defaultBackend.host); + const isLegacySameOriginSeed = isCurrentBrowserOrigin(backend.host); + + if (!matchesDefaultHost && !isLegacySameOriginSeed) { return backend; } return { ...backend, - apiKey: defaultBackend.apiKey, + host: defaultBackend.host, + apiKey: defaultBackend.apiKey || backend.apiKey, }; } +function shouldSeedDefaultLocalBackend(): boolean { + return hasConfiguredAgentServerDefaults(); +} + +function isAutoSeededDefaultLocalBackend(backend: Backend): boolean { + const defaultBackend = makeDefaultLocalBackend(); + + if ( + backend.id !== defaultBackend.id || + backend.kind !== "local" || + backend.name !== defaultBackend.name || + backend.apiKey !== "" + ) { + return false; + } + + const host = normalizeHostForComparison(backend.host); + return ( + host === normalizeHostForComparison(defaultBackend.host) || + host === LEGACY_FRONTEND_ONLY_DEV_BACKEND_URL + ); +} + export function writeStoredBackends(backends: Backend[]): void { if (typeof window === "undefined") return; try { @@ -66,11 +99,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 default local 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 (!shouldSeedDefaultLocalBackend()) return []; const seeded = [makeDefaultLocalBackend()]; writeStoredBackends(seeded); return seeded; @@ -81,19 +114,23 @@ export function readStoredBackends(): Backend[] { const valid = parsed.filter(isValidBackend); // 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 (!shouldSeedDefaultLocalBackend()) return []; const seeded = [makeDefaultLocalBackend()]; writeStoredBackends(seeded); return seeded; } - const synced = valid.map(syncDefaultLocalBackendAuth); - if (synced.some((backend, index) => backend !== valid[index])) { + const configuredDefaults = shouldSeedDefaultLocalBackend(); + const filtered = configuredDefaults + ? valid + : valid.filter((backend) => !isAutoSeededDefaultLocalBackend(backend)); + const synced = filtered.map(syncDefaultLocalBackendConfig); + if ( + synced.length !== valid.length || + synced.some((backend, index) => backend !== valid[index]) + ) { writeStoredBackends(synced); } 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/backends/backend-form-modal.tsx b/src/components/features/backends/backend-form-modal.tsx index 0ce99982d..4cf5213ac 100644 --- a/src/components/features/backends/backend-form-modal.tsx +++ b/src/components/features/backends/backend-form-modal.tsx @@ -149,6 +149,7 @@ function BackendStatusBadge({ retry: false, staleTime: 60_000, enabled: backend.kind === "local" && !disabled, + meta: { disableToast: true }, }); let statusLabel: string; diff --git a/src/components/features/backends/manage-backends-modal.tsx b/src/components/features/backends/manage-backends-modal.tsx index 29e5aafef..68f86aac7 100644 --- a/src/components/features/backends/manage-backends-modal.tsx +++ b/src/components/features/backends/manage-backends-modal.tsx @@ -44,6 +44,7 @@ function BackendVersion({ backend }: { backend: Backend }) { retry: false, staleTime: 60_000, enabled: backend.kind === "local", + meta: { disableToast: true }, }); if (!version) return null; @@ -62,6 +63,12 @@ interface ManageBackendsModalProps { onClose: () => void; } +interface ManageBackendsPanelProps { + onDone: () => void; + doneLabel?: I18nKey; + doneTestId?: string; +} + interface PendingRemoval { id: string; name: string; @@ -121,7 +128,11 @@ function BackendRow({ backend, health, onEdit, onRemove }: BackendRowProps) { ); } -export function ManageBackendsModal({ onClose }: ManageBackendsModalProps) { +export function ManageBackendsPanel({ + onDone, + doneLabel = I18nKey.HOME$DONE, + doneTestId = "manage-backends-done", +}: ManageBackendsPanelProps) { const { t } = useTranslation("openhands"); const { backends, removeBackend } = useActiveBackendContext(); const healthByBackendId = useBackendsHealth(backends, { @@ -142,80 +153,66 @@ export function ManageBackendsModal({ onClose }: ManageBackendsModalProps) { return ( <> - -
- -
-

- {t(I18nKey.BACKEND$MANAGE_TITLE)} -

-
+
+

+ {t(I18nKey.BACKEND$MANAGE_TITLE)} +

+
-
-
- {backends.length === 0 ? ( -

- {t(I18nKey.BACKEND$MANAGE_EMPTY)} -

- ) : ( -
    - {backends.map((backend) => ( - setEditingBackend(backend)} - onRemove={() => - setPendingRemoval({ - id: backend.id, - name: backend.name, - }) - } - /> - ))} -
- )} -
+
+
+ {backends.length === 0 ? ( +

+ {t(I18nKey.BACKEND$MANAGE_EMPTY)} +

+ ) : ( +
    + {backends.map((backend) => ( + setEditingBackend(backend)} + onRemove={() => + setPendingRemoval({ + id: backend.id, + name: backend.name, + }) + } + /> + ))} +
+ )}
+
-
- setShowAddForm(true)} - testId="manage-backends-add" - startContent={} - > - {t(I18nKey.BACKEND$ADD)} - - - {t(I18nKey.HOME$DONE)} - -
+
+ setShowAddForm(true)} + testId="manage-backends-add" + startContent={} + > + {t(I18nKey.BACKEND$ADD)} + + + {t(doneLabel)} +
- +
{showAddForm ? ( setShowAddForm(false)} /> @@ -241,3 +238,30 @@ export function ManageBackendsModal({ onClose }: ManageBackendsModalProps) { ); } + +export function ManageBackendsModal({ onClose }: ManageBackendsModalProps) { + const { t } = useTranslation("openhands"); + + return ( + +
+ + +
+
+ ); +} diff --git a/src/components/features/home/home-chat-launcher.tsx b/src/components/features/home/home-chat-launcher.tsx index 2473decc9..145ec5d59 100644 --- a/src/components/features/home/home-chat-launcher.tsx +++ b/src/components/features/home/home-chat-launcher.tsx @@ -169,9 +169,8 @@ export function HomeChatLauncher() { navigate(`/conversations/${targetConversationId}`); }, - onError: (error) => { + onError: () => { toast.dismiss(toastId); - displayErrorToast(error instanceof Error ? error.message : null); }, }); }; diff --git a/src/components/features/settings/agent-server-onboarding.tsx b/src/components/features/settings/agent-server-onboarding.tsx index ea3f63280..ab53f4c02 100644 --- a/src/components/features/settings/agent-server-onboarding.tsx +++ b/src/components/features/settings/agent-server-onboarding.tsx @@ -1,174 +1,110 @@ import React from "react"; -import { RefreshCw } from "lucide-react"; +import { AlertTriangle } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { - getAgentServerFormDefaults, - saveAgentServerConfig, -} from "#/api/agent-server-config"; -import { - getRegisteredBackends, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import { - DEFAULT_LOCAL_BACKEND_ID, - DEFAULT_LOCAL_BACKEND_NAME, -} from "#/api/backend-registry/default-backend"; -import type { Backend } from "#/api/backend-registry/types"; -import { cn } from "#/utils/utils"; -import { BrandButton } from "./brand-button"; -import { SettingsInput } from "./settings-input"; - -type AgentServerConnectionFormVariant = "settings" | "onboarding"; - -interface AgentServerConnectionFormProps { - className?: string; - formClassName?: string; - variant?: AgentServerConnectionFormVariant; - showSectionHeader?: boolean; +import type { AgentServerUnavailableError } from "#/api/agent-server-compatibility"; +import { getAgentServerFormDefaults } from "#/api/agent-server-config"; +import { I18nKey } from "#/i18n/declaration"; +import { ManageBackendsPanel } from "#/components/features/backends/manage-backends-modal"; +import { useActiveBackendContext } from "#/contexts/active-backend-context"; + +interface AgentServerConnectionScreenProps { + error?: AgentServerUnavailableError | null; } -export function AgentServerConnectionForm({ - className, - formClassName, - variant = "onboarding", - showSectionHeader, -}: AgentServerConnectionFormProps) { - const { t } = useTranslation("openhands"); - const defaults = React.useMemo(() => getAgentServerFormDefaults(), []); - const [baseUrl, setBaseUrl] = React.useState(defaults.baseUrl); - const [sessionApiKey, setSessionApiKey] = React.useState( - defaults.sessionApiKey, - ); - - const formIsClean = - baseUrl === defaults.baseUrl && sessionApiKey === defaults.sessionApiKey; - const isOnboarding = variant === "onboarding"; - const shouldShowSectionHeader = showSectionHeader ?? isOnboarding; - - const reconnect = () => { - window.location.assign("/"); - }; - - const syncDefaultBackendInRegistry = () => { - const trimmedHost = baseUrl.trim(); - if (!trimmedHost) return; - - const trimmedKey = sessionApiKey.trim(); - const current = getRegisteredBackends(); - const defaultEntry: Backend = { - id: DEFAULT_LOCAL_BACKEND_ID, - name: DEFAULT_LOCAL_BACKEND_NAME, - host: trimmedHost, - apiKey: trimmedKey, - kind: "local", +function getStatusKeys( + error: AgentServerUnavailableError | null | undefined, + hasRegisteredBackends: boolean, +) { + if (!error) return null; + + if (error?.reason === "unauthorized") { + return { + title: I18nKey.SETTINGS$AGENT_SERVER_AUTH_STATUS_TITLE, + message: I18nKey.SETTINGS$AGENT_SERVER_AUTH_STATUS_MESSAGE, + showDetails: true, }; + } - const existingIndex = current.findIndex( - (b) => b.id === DEFAULT_LOCAL_BACKEND_ID, - ); - if (existingIndex === -1) { - setRegisteredBackends([defaultEntry, ...current]); - return; - } + const configuredBaseUrl = getAgentServerFormDefaults().baseUrl.trim(); - const next = current.slice(); - next[existingIndex] = { - ...current[existingIndex], - host: trimmedHost, - apiKey: trimmedKey, + if (!configuredBaseUrl && !hasRegisteredBackends) { + return { + title: I18nKey.SETTINGS$AGENT_SERVER_MISSING_STATUS_TITLE, + message: I18nKey.SETTINGS$AGENT_SERVER_MISSING_STATUS_MESSAGE, + showDetails: false, }; - setRegisteredBackends(next); - }; - - const onSubmit = (event: React.FormEvent) => { - event.preventDefault(); + } - // Persist to the legacy config so the next-session seed and any - // module-level fallbacks pick up the new values … - saveAgentServerConfig({ - baseUrl, - sessionApiKey, - }); - - // … and propagate the change into the registry so the active-store - // snapshot reflects the new host/api key on this session too. - syncDefaultBackendInRegistry(); - - reconnect(); + return { + title: I18nKey.SETTINGS$AGENT_SERVER_UNAVAILABLE_STATUS_TITLE, + message: I18nKey.SETTINGS$AGENT_SERVER_UNAVAILABLE_STATUS_MESSAGE, + showDetails: true, }; +} + +export function AgentServerConnectionScreen({ + error, +}: AgentServerConnectionScreenProps) { + const { t } = useTranslation("openhands"); + const { backends } = useActiveBackendContext(); + const status = getStatusKeys(error, backends.length > 0); + const retryConnection = React.useCallback(() => { + window.location.assign("/"); + }, []); return ( -
-
- {shouldShowSectionHeader ? ( +
+

- {t("SETTINGS$AGENT_SERVER_CONNECTION_DETAILS_TITLE")} + {t(I18nKey.SETTINGS$AGENT_SERVER_ONBOARDING_EYEBROW)}

-

- {t("SETTINGS$AGENT_SERVER_CONNECTION_DETAILS_DESCRIPTION")} +

+ {t(I18nKey.SETTINGS$AGENT_SERVER_ONBOARDING_TITLE)} +

+

+ {t(I18nKey.SETTINGS$AGENT_SERVER_ONBOARDING_DESCRIPTION)}

- ) : null} - + +
+

{t(status.title)}

+

+ {t(status.message)} +

+ {status.showDetails && error?.details ? ( +

+ {t(I18nKey.SETTINGS$AGENT_SERVER_DETAILS_LABEL, { + details: error.details, + })} +

+ ) : null} +
+
+ ) : null} + + + - - - -

- {t("SETTINGS$AGENT_SERVER_BROWSER_ONLY_NOTE")} -

-
- -
- } - > - {t("SETTINGS$AGENT_SERVER_RETRY_CONNECTION")} - - - {t("SETTINGS$SAVE_AND_RECONNECT")} -
-
+ ); } diff --git a/src/contexts/active-backend-context.tsx b/src/contexts/active-backend-context.tsx index bfee8baf6..976613a9f 100644 --- a/src/contexts/active-backend-context.tsx +++ b/src/contexts/active-backend-context.tsx @@ -7,7 +7,11 @@ import { setRegisteredBackends, subscribeActiveBackend, } from "#/api/backend-registry/active-store"; -import { makeDefaultLocalBackend } from "#/api/backend-registry/default-backend"; +import { + DEFAULT_LOCAL_BACKEND_ID, + makeDefaultLocalBackend, +} from "#/api/backend-registry/default-backend"; +import { saveAgentServerConfig } from "#/api/agent-server-config"; import { dropBackendHealth, resetBackendHealth, @@ -93,6 +97,18 @@ export function ActiveBackendProvider({ ); setRegisteredBackends(list); + const next = list.find((b) => b.id === id); + if ( + id === DEFAULT_LOCAL_BACKEND_ID && + next?.kind === "local" && + (patch.host !== undefined || patch.apiKey !== undefined) + ) { + saveAgentServerConfig({ + baseUrl: next.host, + sessionApiKey: next.apiKey, + }); + } + // Re-arm health polling when the user edits the fields that // actually drive the probe. Cosmetic edits (name) shouldn't // re-enable a backend that was disabled for being unreachable. @@ -167,3 +183,16 @@ export function useActiveBackend(): ResolvedActiveBackend { if (ctx) return ctx.active; return { backend: makeDefaultLocalBackend(), orgId: null }; } + +export function useEffectiveLocalBackend(): Backend { + const ctx = React.useContext(ActiveBackendContext); + if (!ctx) return makeDefaultLocalBackend(); + + const active = ctx.active.backend; + if (active.kind === "local") return active; + + return ( + ctx.backends.find((backend) => backend.kind === "local") ?? + makeDefaultLocalBackend() + ); +} diff --git a/src/hooks/mutation/use-create-conversation.ts b/src/hooks/mutation/use-create-conversation.ts index cd76f3072..7cad52def 100644 --- a/src/hooks/mutation/use-create-conversation.ts +++ b/src/hooks/mutation/use-create-conversation.ts @@ -4,6 +4,8 @@ import { PluginSpec } from "#/api/conversation-service/agent-server-conversation import { SuggestedTask } from "#/utils/types"; import { Provider } from "#/types/settings"; import { useTracking } from "#/hooks/use-tracking"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import { maybeCreateAgentServerCorsError } from "#/utils/agent-server-cors-error"; interface CreateConversationVariables { query?: string; @@ -30,6 +32,7 @@ interface CreateConversationResponse { export const useCreateConversation = () => { const queryClient = useQueryClient(); const { trackConversationCreated } = useTracking(); + const { backend } = useActiveBackend(); return useMutation({ mutationKey: ["create-conversation"], @@ -46,8 +49,11 @@ export const useCreateConversation = () => { agentType, } = variables; - const conversation = - await AgentServerConversationService.createConversation( + let conversation: Awaited< + ReturnType + >; + try { + conversation = await AgentServerConversationService.createConversation( query, conversationInstructions, plugins, @@ -62,6 +68,10 @@ export const useCreateConversation = () => { parentConversationId, agentType, ); + } catch (error) { + const corsError = maybeCreateAgentServerCorsError(error, backend); + throw corsError ?? error; + } // OpenHands cloud pattern: when the start task isn't immediately // READY (cloud sandbox is still provisioning), diff --git a/src/hooks/query/query-keys.test.ts b/src/hooks/query/query-keys.test.ts index 41790ce59..79e8b637d 100644 --- a/src/hooks/query/query-keys.test.ts +++ b/src/hooks/query/query-keys.test.ts @@ -1,5 +1,24 @@ import { describe, expect, it } from "vitest"; -import { SETTINGS_QUERY_KEYS } from "./query-keys"; +import { QUERY_KEYS, SETTINGS_QUERY_KEYS } from "./query-keys"; + +describe("QUERY_KEYS", () => { + it("builds backend-scoped web client config keys", () => { + expect( + QUERY_KEYS.WEB_CLIENT_CONFIG_BY_BACKEND({ + id: "local-1", + kind: "local", + host: "http://localhost:8000", + apiKey: "session-key", + }), + ).toEqual([ + "web-client-config", + "local-1", + "local", + "http://localhost:8000", + "session-key", + ]); + }); +}); describe("SETTINGS_QUERY_KEYS", () => { it("returns the canonical root settings key", () => { diff --git a/src/hooks/query/query-keys.ts b/src/hooks/query/query-keys.ts index 2cf1a5ad0..825c43dcf 100644 --- a/src/hooks/query/query-keys.ts +++ b/src/hooks/query/query-keys.ts @@ -5,9 +5,26 @@ import { SettingsScope } from "#/types/settings"; +interface BackendQueryIdentity { + id: string; + kind: string; + host: string; + apiKey?: string | null; +} + +const WEB_CLIENT_CONFIG_QUERY_KEY = ["web-client-config"] as const; + export const QUERY_KEYS = { /** Web client configuration from the server */ - WEB_CLIENT_CONFIG: ["web-client-config"] as const, + WEB_CLIENT_CONFIG: WEB_CLIENT_CONFIG_QUERY_KEY, + WEB_CLIENT_CONFIG_BY_BACKEND: (backend: BackendQueryIdentity) => + [ + ...WEB_CLIENT_CONFIG_QUERY_KEY, + backend.id, + backend.kind, + backend.host, + backend.apiKey ?? "", + ] as const, } as const; export const SETTINGS_QUERY_KEYS = { diff --git a/src/hooks/query/use-config.ts b/src/hooks/query/use-config.ts index 39e121e05..2db118967 100644 --- a/src/hooks/query/use-config.ts +++ b/src/hooks/query/use-config.ts @@ -1,15 +1,18 @@ import { useQuery } from "@tanstack/react-query"; import { isAgentServerUnavailableError } from "#/api/agent-server-compatibility"; import OptionService from "#/api/option-service/option-service.api"; +import { useEffectiveLocalBackend } from "#/contexts/active-backend-context"; import { QUERY_KEYS, CONFIG_CACHE_OPTIONS } from "./query-keys"; interface UseConfigOptions { enabled?: boolean; } -export const useConfig = (options?: UseConfigOptions) => - useQuery({ - queryKey: QUERY_KEYS.WEB_CLIENT_CONFIG, +export const useConfig = (options?: UseConfigOptions) => { + const backend = useEffectiveLocalBackend(); + + return useQuery({ + queryKey: QUERY_KEYS.WEB_CLIENT_CONFIG_BY_BACKEND(backend), queryFn: OptionService.getConfig, retry: (failureCount, error) => !isAgentServerUnavailableError(error) && failureCount < 3, @@ -17,3 +20,4 @@ export const useConfig = (options?: UseConfigOptions) => ...CONFIG_CACHE_OPTIONS, enabled: options?.enabled, }); +}; diff --git a/src/hooks/query/use-resolved-workspaces.ts b/src/hooks/query/use-resolved-workspaces.ts index 94aca5bcd..7d1dcf515 100644 --- a/src/hooks/query/use-resolved-workspaces.ts +++ b/src/hooks/query/use-resolved-workspaces.ts @@ -17,17 +17,30 @@ interface UseResolvedWorkspacesResult { } /** - * Implicit workspace parents that are always considered when resolving - * workspaces. `/projects` is a well-known directory that some agent-server - * setups use as the projects root. We surface its immediate subdirectories - * as workspaces automatically in dev mode. + * Implicit workspace parents that are considered when resolving workspaces in + * a launcher-managed dev stack. `/projects` is a well-known directory that + * some agent-server setups use as the projects root. We surface its immediate + * subdirectories as workspaces automatically when the launcher advertises that + * it started an agent server. * - * This is a development convenience only. Production previews may point at - * arbitrary remote agent servers that do not expose the file-browser endpoint; - * probing `/projects` there creates noisy 404s before the user has added any - * workspace parent explicitly. + * This is a development convenience only. Frontend-only dev may point at + * arbitrary user-selected agent servers that do not expose the file-browser + * endpoint; probing `/projects` there creates noisy 404s before the user has + * added any workspace parent explicitly. */ -const INCLUDE_IMPLICIT_WORKSPACE_PARENTS = import.meta.env.DEV; +function shouldIncludeImplicitWorkspaceParents(): boolean { + if (!import.meta.env.DEV) return false; + + const raw = import.meta.env.VITE_RUNTIME_SERVICES_INFO?.trim(); + if (!raw) return false; + + try { + const parsed = JSON.parse(raw) as { services?: Record }; + return Boolean(parsed.services?.agent_server); + } catch { + return false; + } +} const IMPLICIT_WORKSPACE_PARENTS: LocalWorkspaceParent[] = [ { id: "implicit:/projects", name: "/projects", path: "/projects" }, @@ -62,7 +75,7 @@ export function useResolvedWorkspaces(): UseResolvedWorkspacesResult { // Filter out implicit parents that conflict with user-added ones (by path) // so custom names/ids are preserved. const implicitParents = - INCLUDE_IMPLICIT_WORKSPACE_PARENTS && !workspacesUnsupported + shouldIncludeImplicitWorkspaceParents() && !workspacesUnsupported ? IMPLICIT_WORKSPACE_PARENTS : []; const extras = implicitParents.filter((p) => !seen.has(p.path)); diff --git a/src/i18n/translation.json b/src/i18n/translation.json index 0bb8bf1a0..946fed15d 100644 --- a/src/i18n/translation.json +++ b/src/i18n/translation.json @@ -6121,105 +6121,173 @@ }, "SETTINGS$AGENT_SERVER_RETRY_CONNECTION": { "en": "Retry connection", - "ja": "Retry connection", - "zh-CN": "Retry connection", - "zh-TW": "Retry connection", - "ko-KR": "Retry connection", - "no": "Retry connection", - "it": "Retry connection", - "pt": "Retry connection", - "es": "Retry connection", - "ar": "Retry connection", - "fr": "Retry connection", - "tr": "Retry connection", - "de": "Retry connection", - "uk": "Retry connection", - "ca": "Retry connection" + "ja": "接続を再試行", + "zh-CN": "重试连接", + "zh-TW": "重試連線", + "ko-KR": "연결 다시 시도", + "no": "Prøv tilkobling på nytt", + "it": "Riprova connessione", + "pt": "Tentar conexão novamente", + "es": "Reintentar conexión", + "ar": "إعادة محاولة الاتصال", + "fr": "Réessayer la connexion", + "tr": "Bağlantıyı yeniden dene", + "de": "Verbindung erneut versuchen", + "uk": "Повторити підключення", + "ca": "Torna a provar la connexió" }, "SETTINGS$AGENT_SERVER_ONBOARDING_EYEBROW": { "en": "Get started", - "ja": "Get started", - "zh-CN": "Get started", - "zh-TW": "Get started", - "ko-KR": "Get started", - "no": "Get started", - "it": "Get started", - "pt": "Get started", - "es": "Get started", - "ar": "Get started", - "fr": "Get started", - "tr": "Get started", - "de": "Get started", - "uk": "Get started", - "ca": "Get started" + "ja": "はじめる", + "zh-CN": "开始使用", + "zh-TW": "開始使用", + "ko-KR": "시작하기", + "no": "Kom i gang", + "it": "Inizia", + "pt": "Começar", + "es": "Primeros pasos", + "ar": "ابدأ", + "fr": "Commencer", + "tr": "Başlayın", + "de": "Loslegen", + "uk": "Почати", + "ca": "Comença" }, "SETTINGS$AGENT_SERVER_ONBOARDING_TITLE": { "en": "Connect to your agent server", - "ja": "Connect to your agent server", - "zh-CN": "Connect to your agent server", - "zh-TW": "Connect to your agent server", - "ko-KR": "Connect to your agent server", - "no": "Connect to your agent server", - "it": "Connect to your agent server", - "pt": "Connect to your agent server", - "es": "Connect to your agent server", - "ar": "Connect to your agent server", - "fr": "Connect to your agent server", - "tr": "Connect to your agent server", - "de": "Connect to your agent server", - "uk": "Connect to your agent server", - "ca": "Connect to your agent server" + "ja": "エージェントサーバーに接続", + "zh-CN": "连接到你的代理服务器", + "zh-TW": "連線到你的代理伺服器", + "ko-KR": "에이전트 서버에 연결", + "no": "Koble til agentserveren din", + "it": "Connettiti al tuo server agente", + "pt": "Conectar ao servidor do agente", + "es": "Conectar con tu servidor del agente", + "ar": "الاتصال بخادم الوكيل الخاص بك", + "fr": "Connectez-vous à votre serveur d'agent", + "tr": "Aracı sunucunuza bağlanın", + "de": "Mit deinem Agent-Server verbinden", + "uk": "Підключіться до сервера агента", + "ca": "Connecta't al servidor de l'agent" }, "SETTINGS$AGENT_SERVER_ONBOARDING_DESCRIPTION": { "en": "Agent Canvas needs an agent server before it can load conversations, tools, and settings. Start or choose a compatible server, then connect it here.", - "ja": "Agent Canvas needs an agent server before it can load conversations, tools, and settings. Start or choose a compatible server, then connect it here.", - "zh-CN": "Agent Canvas needs an agent server before it can load conversations, tools, and settings. Start or choose a compatible server, then connect it here.", - "zh-TW": "Agent Canvas needs an agent server before it can load conversations, tools, and settings. Start or choose a compatible server, then connect it here.", - "ko-KR": "Agent Canvas needs an agent server before it can load conversations, tools, and settings. Start or choose a compatible server, then connect it here.", - "no": "Agent Canvas needs an agent server before it can load conversations, tools, and settings. Start or choose a compatible server, then connect it here.", - "it": "Agent Canvas needs an agent server before it can load conversations, tools, and settings. Start or choose a compatible server, then connect it here.", - "pt": "Agent Canvas needs an agent server before it can load conversations, tools, and settings. Start or choose a compatible server, then connect it here.", - "es": "Agent Canvas needs an agent server before it can load conversations, tools, and settings. Start or choose a compatible server, then connect it here.", - "ar": "Agent Canvas needs an agent server before it can load conversations, tools, and settings. Start or choose a compatible server, then connect it here.", - "fr": "Agent Canvas needs an agent server before it can load conversations, tools, and settings. Start or choose a compatible server, then connect it here.", - "tr": "Agent Canvas needs an agent server before it can load conversations, tools, and settings. Start or choose a compatible server, then connect it here.", - "de": "Agent Canvas needs an agent server before it can load conversations, tools, and settings. Start or choose a compatible server, then connect it here.", - "uk": "Agent Canvas needs an agent server before it can load conversations, tools, and settings. Start or choose a compatible server, then connect it here.", - "ca": "Agent Canvas needs an agent server before it can load conversations, tools, and settings. Start or choose a compatible server, then connect it here." + "ja": "Agent Canvas が会話、ツール、設定を読み込むにはエージェントサーバーが必要です。互換性のあるサーバーを起動または選択して、ここで接続してください。", + "zh-CN": "Agent Canvas 需要代理服务器才能加载对话、工具和设置。启动或选择兼容的服务器,然后在此处连接。", + "zh-TW": "Agent Canvas 需要代理伺服器才能載入對話、工具和設定。啟動或選擇相容的伺服器,然後在這裡連線。", + "ko-KR": "Agent Canvas가 대화, 도구, 설정을 불러오려면 에이전트 서버가 필요합니다. 호환되는 서버를 시작하거나 선택한 다음 여기에서 연결하세요.", + "no": "Agent Canvas trenger en agentserver før den kan laste samtaler, verktøy og innstillinger. Start eller velg en kompatibel server, og koble den til her.", + "it": "Agent Canvas richiede un server agente prima di poter caricare conversazioni, strumenti e impostazioni. Avvia o scegli un server compatibile, quindi connettilo qui.", + "pt": "O Agent Canvas precisa de um servidor do agente antes de carregar conversas, ferramentas e configurações. Inicie ou escolha um servidor compatível e conecte-o aqui.", + "es": "Agent Canvas necesita un servidor del agente para cargar conversaciones, herramientas y ajustes. Inicia o elige un servidor compatible y conéctalo aquí.", + "ar": "يحتاج Agent Canvas إلى خادم وكيل قبل أن يتمكن من تحميل المحادثات والأدوات والإعدادات. شغّل خادما متوافقا أو اختره، ثم اتصل به هنا.", + "fr": "Agent Canvas a besoin d'un serveur d'agent avant de pouvoir charger les conversations, les outils et les paramètres. Démarrez ou choisissez un serveur compatible, puis connectez-le ici.", + "tr": "Agent Canvas'ın konuşmaları, araçları ve ayarları yükleyebilmesi için bir aracı sunucu gerekir. Uyumlu bir sunucu başlatın veya seçin, ardından burada bağlanın.", + "de": "Agent Canvas benötigt einen Agent-Server, bevor Konversationen, Tools und Einstellungen geladen werden können. Starte oder wähle einen kompatiblen Server und verbinde ihn hier.", + "uk": "Agent Canvas потрібен сервер агента, перш ніж він зможе завантажувати розмови, інструменти та налаштування. Запустіть або виберіть сумісний сервер, а потім підключіть його тут.", + "ca": "Agent Canvas necessita un servidor de l'agent abans de poder carregar converses, eines i configuració. Inicia o tria un servidor compatible i connecta'l aquí." + }, + "SETTINGS$AGENT_SERVER_MISSING_STATUS_TITLE": { + "en": "No backend is configured", + "ja": "バックエンドが設定されていません", + "zh-CN": "未配置后端", + "zh-TW": "尚未設定後端", + "ko-KR": "백엔드가 구성되지 않았습니다", + "no": "Ingen backend er konfigurert", + "it": "Nessun backend configurato", + "pt": "Nenhum backend configurado", + "es": "No hay ningún backend configurado", + "ar": "لم يتم تكوين أي خلفية", + "fr": "Aucun backend n'est configuré", + "tr": "Hiçbir arka uç yapılandırılmadı", + "de": "Kein Backend konfiguriert", + "uk": "Бекенд не налаштовано", + "ca": "No hi ha cap backend configurat" + }, + "SETTINGS$AGENT_SERVER_MISSING_STATUS_MESSAGE": { + "en": "Enter the agent server URL and session API key to connect this browser.", + "ja": "このブラウザーを接続するには、エージェントサーバー URL とセッション API キーを入力してください。", + "zh-CN": "输入代理服务器 URL 和会话 API 密钥以连接此浏览器。", + "zh-TW": "輸入代理伺服器 URL 和工作階段 API 金鑰,以連線此瀏覽器。", + "ko-KR": "이 브라우저를 연결하려면 에이전트 서버 URL과 세션 API 키를 입력하세요.", + "no": "Skriv inn agentserver-URL-en og økt-API-nøkkelen for å koble til denne nettleseren.", + "it": "Inserisci l'URL del server agente e la chiave API di sessione per connettere questo browser.", + "pt": "Insira a URL do servidor do agente e a chave de API de sessão para conectar este navegador.", + "es": "Introduce la URL del servidor del agente y la clave API de sesión para conectar este navegador.", + "ar": "أدخل عنوان URL لخادم الوكيل ومفتاح API للجلسة لتوصيل هذا المتصفح.", + "fr": "Saisissez l'URL du serveur d'agent et la clé d'API de session pour connecter ce navigateur.", + "tr": "Bu tarayıcıyı bağlamak için aracı sunucu URL'sini ve oturum API anahtarını girin.", + "de": "Gib die Agent-Server-URL und den Sitzungs-API-Schlüssel ein, um diesen Browser zu verbinden.", + "uk": "Введіть URL сервера агента та сесійний API-ключ, щоб підключити цей браузер.", + "ca": "Introdueix l'URL del servidor de l'agent i la clau d'API de sessió per connectar aquest navegador." + }, + "SETTINGS$AGENT_SERVER_AUTH_STATUS_TITLE": { + "en": "Backend authentication failed", + "ja": "バックエンド認証に失敗しました", + "zh-CN": "后端身份验证失败", + "zh-TW": "後端驗證失敗", + "ko-KR": "백엔드 인증에 실패했습니다", + "no": "Backend-autentisering mislyktes", + "it": "Autenticazione del backend non riuscita", + "pt": "Falha na autenticação do backend", + "es": "Error de autenticación del backend", + "ar": "فشلت مصادقة الخلفية", + "fr": "Échec de l'authentification du backend", + "tr": "Arka uç kimlik doğrulaması başarısız oldu", + "de": "Backend-Authentifizierung fehlgeschlagen", + "uk": "Не вдалося автентифікуватися в бекенді", + "ca": "Ha fallat l'autenticació del backend" + }, + "SETTINGS$AGENT_SERVER_AUTH_STATUS_MESSAGE": { + "en": "The configured server rejected the session API key. Update the key below, or point Agent Canvas at a different backend.", + "ja": "設定済みサーバーがセッション API キーを拒否しました。下のキーを更新するか、Agent Canvas を別のバックエンドに接続してください。", + "zh-CN": "已配置的服务器拒绝了会话 API 密钥。请更新下面的密钥,或将 Agent Canvas 指向其他后端。", + "zh-TW": "已設定的伺服器拒絕了工作階段 API 金鑰。請更新下方的金鑰,或將 Agent Canvas 指向其他後端。", + "ko-KR": "구성된 서버가 세션 API 키를 거부했습니다. 아래 키를 업데이트하거나 Agent Canvas를 다른 백엔드로 지정하세요.", + "no": "Den konfigurerte serveren avviste økt-API-nøkkelen. Oppdater nøkkelen nedenfor, eller pek Agent Canvas mot en annen backend.", + "it": "Il server configurato ha rifiutato la chiave API di sessione. Aggiorna la chiave qui sotto oppure punta Agent Canvas a un backend diverso.", + "pt": "O servidor configurado rejeitou a chave de API de sessão. Atualize a chave abaixo ou aponte o Agent Canvas para outro backend.", + "es": "El servidor configurado rechazó la clave API de sesión. Actualiza la clave abajo o apunta Agent Canvas a otro backend.", + "ar": "رفض الخادم المكوّن مفتاح API للجلسة. حدّث المفتاح أدناه أو وجّه Agent Canvas إلى خلفية مختلفة.", + "fr": "Le serveur configuré a rejeté la clé d'API de session. Mettez à jour la clé ci-dessous ou pointez Agent Canvas vers un autre backend.", + "tr": "Yapılandırılmış sunucu oturum API anahtarını reddetti. Aşağıdaki anahtarı güncelleyin veya Agent Canvas'ı farklı bir arka uca yönlendirin.", + "de": "Der konfigurierte Server hat den Sitzungs-API-Schlüssel abgelehnt. Aktualisiere den Schlüssel unten oder richte Agent Canvas auf ein anderes Backend aus.", + "uk": "Налаштований сервер відхилив сесійний API-ключ. Оновіть ключ нижче або спрямуйте Agent Canvas на інший бекенд.", + "ca": "El servidor configurat ha rebutjat la clau d'API de sessió. Actualitza la clau de sota o apunta Agent Canvas a un altre backend." }, "SETTINGS$AGENT_SERVER_UNAVAILABLE_STATUS_TITLE": { "en": "We couldn't reach the configured server", - "ja": "We couldn't reach the configured server", - "zh-CN": "We couldn't reach the configured server", - "zh-TW": "We couldn't reach the configured server", - "ko-KR": "We couldn't reach the configured server", - "no": "We couldn't reach the configured server", - "it": "We couldn't reach the configured server", - "pt": "We couldn't reach the configured server", - "es": "We couldn't reach the configured server", - "ar": "We couldn't reach the configured server", - "fr": "We couldn't reach the configured server", - "tr": "We couldn't reach the configured server", - "de": "We couldn't reach the configured server", - "uk": "We couldn't reach the configured server", - "ca": "We couldn't reach the configured server" + "ja": "設定済みサーバーに接続できませんでした", + "zh-CN": "无法连接到已配置的服务器", + "zh-TW": "無法連線到已設定的伺服器", + "ko-KR": "구성된 서버에 연결할 수 없습니다", + "no": "Vi kunne ikke nå den konfigurerte serveren", + "it": "Impossibile raggiungere il server configurato", + "pt": "Não foi possível acessar o servidor configurado", + "es": "No pudimos conectar con el servidor configurado", + "ar": "تعذر الوصول إلى الخادم المكوّن", + "fr": "Impossible de joindre le serveur configuré", + "tr": "Yapılandırılmış sunucuya ulaşılamadı", + "de": "Der konfigurierte Server konnte nicht erreicht werden", + "uk": "Не вдалося підключитися до налаштованого сервера", + "ca": "No hem pogut arribar al servidor configurat" }, "SETTINGS$AGENT_SERVER_UNAVAILABLE_STATUS_MESSAGE": { "en": "Check the URL, confirm the server is running, and try again. You can also point Agent Canvas at a different deployment.", - "ja": "Check the URL, confirm the server is running, and try again. You can also point Agent Canvas at a different deployment.", - "zh-CN": "Check the URL, confirm the server is running, and try again. You can also point Agent Canvas at a different deployment.", - "zh-TW": "Check the URL, confirm the server is running, and try again. You can also point Agent Canvas at a different deployment.", - "ko-KR": "Check the URL, confirm the server is running, and try again. You can also point Agent Canvas at a different deployment.", - "no": "Check the URL, confirm the server is running, and try again. You can also point Agent Canvas at a different deployment.", - "it": "Check the URL, confirm the server is running, and try again. You can also point Agent Canvas at a different deployment.", - "pt": "Check the URL, confirm the server is running, and try again. You can also point Agent Canvas at a different deployment.", - "es": "Check the URL, confirm the server is running, and try again. You can also point Agent Canvas at a different deployment.", - "ar": "Check the URL, confirm the server is running, and try again. You can also point Agent Canvas at a different deployment.", - "fr": "Check the URL, confirm the server is running, and try again. You can also point Agent Canvas at a different deployment.", - "tr": "Check the URL, confirm the server is running, and try again. You can also point Agent Canvas at a different deployment.", - "de": "Check the URL, confirm the server is running, and try again. You can also point Agent Canvas at a different deployment.", - "uk": "Check the URL, confirm the server is running, and try again. You can also point Agent Canvas at a different deployment.", - "ca": "Check the URL, confirm the server is running, and try again. You can also point Agent Canvas at a different deployment." + "ja": "URL を確認し、サーバーが実行中であることを確認してから再試行してください。Agent Canvas を別のデプロイに接続することもできます。", + "zh-CN": "检查 URL,确认服务器正在运行,然后重试。你也可以将 Agent Canvas 指向其他部署。", + "zh-TW": "檢查 URL,確認伺服器正在執行,然後再試一次。你也可以將 Agent Canvas 指向其他部署。", + "ko-KR": "URL을 확인하고 서버가 실행 중인지 확인한 다음 다시 시도하세요. Agent Canvas를 다른 배포로 지정할 수도 있습니다.", + "no": "Kontroller URL-en, bekreft at serveren kjører, og prøv på nytt. Du kan også peke Agent Canvas mot en annen distribusjon.", + "it": "Controlla l'URL, verifica che il server sia in esecuzione e riprova. Puoi anche puntare Agent Canvas a una distribuzione diversa.", + "pt": "Verifique a URL, confirme que o servidor está em execução e tente novamente. Você também pode apontar o Agent Canvas para outra implantação.", + "es": "Comprueba la URL, confirma que el servidor esté en ejecución e inténtalo de nuevo. También puedes apuntar Agent Canvas a otro despliegue.", + "ar": "تحقق من عنوان URL، وتأكد من أن الخادم يعمل، ثم حاول مرة أخرى. يمكنك أيضا توجيه Agent Canvas إلى نشر مختلف.", + "fr": "Vérifiez l'URL, confirmez que le serveur est en cours d'exécution, puis réessayez. Vous pouvez aussi pointer Agent Canvas vers un autre déploiement.", + "tr": "URL'yi kontrol edin, sunucunun çalıştığını doğrulayın ve yeniden deneyin. Agent Canvas'ı farklı bir dağıtıma da yönlendirebilirsiniz.", + "de": "Prüfe die URL, bestätige, dass der Server läuft, und versuche es erneut. Du kannst Agent Canvas auch auf eine andere Bereitstellung ausrichten.", + "uk": "Перевірте URL, переконайтеся, що сервер запущено, і спробуйте ще раз. Ви також можете спрямувати Agent Canvas на інше розгортання.", + "ca": "Comprova l'URL, confirma que el servidor s'està executant i torna-ho a provar. També pots apuntar Agent Canvas a un altre desplegament." }, "SETTINGS$AGENT_SERVER_OPEN_SETTINGS_PAGE": { "en": "Open full settings page", @@ -6274,20 +6342,20 @@ }, "SETTINGS$AGENT_SERVER_DETAILS_LABEL": { "en": "Details: {{details}}", - "ja": "Details: {{details}}", - "zh-CN": "Details: {{details}}", - "zh-TW": "Details: {{details}}", - "ko-KR": "Details: {{details}}", - "no": "Details: {{details}}", - "it": "Details: {{details}}", - "pt": "Details: {{details}}", - "es": "Details: {{details}}", - "ar": "Details: {{details}}", - "fr": "Details: {{details}}", - "tr": "Details: {{details}}", + "ja": "詳細: {{details}}", + "zh-CN": "详情: {{details}}", + "zh-TW": "詳細資料: {{details}}", + "ko-KR": "세부 정보: {{details}}", + "no": "Detaljer: {{details}}", + "it": "Dettagli: {{details}}", + "pt": "Detalhes: {{details}}", + "es": "Detalles: {{details}}", + "ar": "التفاصيل: {{details}}", + "fr": "Détails: {{details}}", + "tr": "Ayrıntılar: {{details}}", "de": "Details: {{details}}", - "uk": "Details: {{details}}", - "ca": "Details: {{details}}" + "uk": "Деталі: {{details}}", + "ca": "Detalls: {{details}}" }, "SETTINGS$SOUND_NOTIFICATIONS": { "en": "Sound Notifications", @@ -6544,6 +6612,23 @@ "uk": "Сталася помилка", "ca": "S'ha produït un error" }, + "ERROR$AGENT_SERVER_CORS": { + "en": "Agent Canvas could not reach the agent server. The browser reported a network failure while contacting a different origin, which is usually a CORS block.\n\nFrontend origin: {{frontendOrigin}}\nBackend: {{backendOrigin}}\n\nRestart the agent server with this frontend origin in its CORS allow-list. For OpenHands Agent Server, set `OH_ALLOW_CORS_ORIGINS='[\"{{frontendOrigin}}\"]'` when launching `agent-server`, keeping your existing host and port options.", + "ja": "Agent Canvas はエージェントサーバーに接続できませんでした。ブラウザーが別のオリジンへの接続中にネットワーク障害を報告しました。通常は CORS によるブロックです。\n\nフロントエンドのオリジン: {{frontendOrigin}}\nバックエンド: {{backendOrigin}}\n\nこのフロントエンドのオリジンを CORS 許可リストに入れてエージェントサーバーを再起動してください。OpenHands Agent Server では、既存のホストとポートのオプションはそのままにして、`agent-server` の起動時に `OH_ALLOW_CORS_ORIGINS='[\"{{frontendOrigin}}\"]'` を設定します。", + "zh-CN": "Agent Canvas 无法连接到代理服务器。浏览器在访问另一个源时报告了网络故障,这通常是 CORS 阻止导致的。\n\n前端源: {{frontendOrigin}}\n后端: {{backendOrigin}}\n\n请在 CORS 允许列表中加入此前端源后重启代理服务器。对于 OpenHands Agent Server,在启动 `agent-server` 时设置 `OH_ALLOW_CORS_ORIGINS='[\"{{frontendOrigin}}\"]'`,并保留你现有的主机和端口选项。", + "zh-TW": "Agent Canvas 無法連線到代理伺服器。瀏覽器在存取不同來源時回報網路失敗,這通常是 CORS 封鎖造成的。\n\n前端來源: {{frontendOrigin}}\n後端: {{backendOrigin}}\n\n請將此前端來源加入 CORS 允許清單後重新啟動代理伺服器。對於 OpenHands Agent Server,啟動 `agent-server` 時設定 `OH_ALLOW_CORS_ORIGINS='[\"{{frontendOrigin}}\"]'`,並保留你既有的主機與連接埠選項。", + "ko-KR": "Agent Canvas가 에이전트 서버에 연결할 수 없습니다. 브라우저가 다른 출처에 연결하는 동안 네트워크 실패를 보고했으며, 이는 보통 CORS 차단 때문입니다.\n\n프론트엔드 출처: {{frontendOrigin}}\n백엔드: {{backendOrigin}}\n\n이 프론트엔드 출처를 CORS 허용 목록에 넣어 에이전트 서버를 다시 시작하세요. OpenHands Agent Server의 경우 기존 호스트와 포트 옵션은 유지하고 `agent-server`를 시작할 때 `OH_ALLOW_CORS_ORIGINS='[\"{{frontendOrigin}}\"]'`를 설정하세요.", + "de": "Agent Canvas konnte den Agent-Server nicht erreichen. Der Browser hat beim Kontaktieren eines anderen Ursprungs einen Netzwerkfehler gemeldet, was meistens durch eine CORS-Sperre verursacht wird.\n\nFrontend-Ursprung: {{frontendOrigin}}\nBackend: {{backendOrigin}}\n\nStarte den Agent-Server neu und nimm diesen Frontend-Ursprung in die CORS-Allowlist auf. Beim OpenHands Agent Server setzt du beim Start von `agent-server` `OH_ALLOW_CORS_ORIGINS='[\"{{frontendOrigin}}\"]'` und behältst deine bestehenden Host- und Port-Optionen bei.", + "no": "Agent Canvas kunne ikke nå agentserveren. Nettleseren rapporterte en nettverksfeil ved kontakt med en annen opprinnelse, som vanligvis skyldes en CORS-blokkering.\n\nFrontend-opprinnelse: {{frontendOrigin}}\nBackend: {{backendOrigin}}\n\nStart agentserveren på nytt med denne frontend-opprinnelsen i CORS-tillatelseslisten. For OpenHands Agent Server setter du `OH_ALLOW_CORS_ORIGINS='[\"{{frontendOrigin}}\"]'` når du starter `agent-server`, og beholder eksisterende vert- og portalternativer.", + "it": "Agent Canvas non è riuscito a raggiungere il server agente. Il browser ha segnalato un errore di rete durante il contatto con un'origine diversa; di solito si tratta di un blocco CORS.\n\nOrigine frontend: {{frontendOrigin}}\nBackend: {{backendOrigin}}\n\nRiavvia il server agente includendo questa origine frontend nella allowlist CORS. Per OpenHands Agent Server, imposta `OH_ALLOW_CORS_ORIGINS='[\"{{frontendOrigin}}\"]'` quando avvii `agent-server`, mantenendo le opzioni host e porta esistenti.", + "pt": "O Agent Canvas não conseguiu acessar o servidor do agente. O navegador relatou uma falha de rede ao contatar uma origem diferente, o que geralmente indica um bloqueio de CORS.\n\nOrigem do frontend: {{frontendOrigin}}\nBackend: {{backendOrigin}}\n\nReinicie o servidor do agente com esta origem do frontend na lista de permissões de CORS. Para o OpenHands Agent Server, defina `OH_ALLOW_CORS_ORIGINS='[\"{{frontendOrigin}}\"]'` ao iniciar `agent-server`, mantendo as opções atuais de host e porta.", + "es": "Agent Canvas no pudo conectarse con el servidor del agente. El navegador informó un fallo de red al contactar con un origen diferente, lo que normalmente indica un bloqueo de CORS.\n\nOrigen del frontend: {{frontendOrigin}}\nBackend: {{backendOrigin}}\n\nReinicia el servidor del agente con este origen del frontend en la lista permitida de CORS. Para OpenHands Agent Server, define `OH_ALLOW_CORS_ORIGINS='[\"{{frontendOrigin}}\"]'` al iniciar `agent-server`, manteniendo tus opciones actuales de host y puerto.", + "ar": "تعذر على Agent Canvas الوصول إلى خادم الوكيل. أبلغ المتصفح عن فشل في الشبكة أثناء الاتصال بمصدر مختلف، وهذا يحدث عادة بسبب حظر CORS.\n\nمصدر الواجهة الأمامية: {{frontendOrigin}}\nالخلفية: {{backendOrigin}}\n\nأعد تشغيل خادم الوكيل مع إضافة مصدر الواجهة الأمامية هذا إلى قائمة السماح الخاصة بـ CORS. بالنسبة إلى OpenHands Agent Server، عيّن `OH_ALLOW_CORS_ORIGINS='[\"{{frontendOrigin}}\"]'` عند تشغيل `agent-server` مع إبقاء خيارات المضيف والمنفذ الحالية كما هي.", + "fr": "Agent Canvas n'a pas pu joindre le serveur d'agent. Le navigateur a signalé un échec réseau en contactant une autre origine, ce qui correspond généralement à un blocage CORS.\n\nOrigine du frontend: {{frontendOrigin}}\nBackend: {{backendOrigin}}\n\nRedémarrez le serveur d'agent avec cette origine frontend dans la liste d'autorisation CORS. Pour OpenHands Agent Server, définissez `OH_ALLOW_CORS_ORIGINS='[\"{{frontendOrigin}}\"]'` au lancement de `agent-server`, en conservant vos options d'hôte et de port existantes.", + "tr": "Agent Canvas aracı sunucuya ulaşamadı. Tarayıcı farklı bir kaynağa bağlanırken ağ hatası bildirdi; bu genellikle CORS engellemesidir.\n\nÖn uç kaynağı: {{frontendOrigin}}\nArka uç: {{backendOrigin}}\n\nAracı sunucuyu bu ön uç kaynağı CORS izin listesinde olacak şekilde yeniden başlatın. OpenHands Agent Server için mevcut ana makine ve bağlantı noktası seçeneklerinizi koruyarak `agent-server` başlatılırken `OH_ALLOW_CORS_ORIGINS='[\"{{frontendOrigin}}\"]'` değerini ayarlayın.", + "uk": "Agent Canvas не зміг підключитися до сервера агента. Браузер повідомив про мережеву помилку під час звернення до іншого джерела, що зазвичай означає блокування CORS.\n\nДжерело фронтенду: {{frontendOrigin}}\nБекенд: {{backendOrigin}}\n\nПерезапустіть сервер агента з цим джерелом фронтенду в allow-list CORS. Для OpenHands Agent Server під час запуску `agent-server` задайте `OH_ALLOW_CORS_ORIGINS='[\"{{frontendOrigin}}\"]'`, залишивши поточні параметри хоста й порту.", + "ca": "Agent Canvas no ha pogut arribar al servidor de l'agent. El navegador ha informat d'un error de xarxa en contactar amb un origen diferent, cosa que normalment indica un bloqueig de CORS.\n\nOrigen del frontend: {{frontendOrigin}}\nBackend: {{backendOrigin}}\n\nReinicia el servidor de l'agent amb aquest origen del frontend a la llista permesa de CORS. Per a OpenHands Agent Server, defineix `OH_ALLOW_CORS_ORIGINS='[\"{{frontendOrigin}}\"]'` en iniciar `agent-server`, mantenint les opcions actuals d'amfitrió i port." + }, "GITHUB$AUTH_SCOPE": { "en": "openid email profile", "ja": "openid email profile", diff --git a/src/root.tsx b/src/root.tsx index 35855120b..b29cc59c6 100644 --- a/src/root.tsx +++ b/src/root.tsx @@ -2,9 +2,11 @@ import { Links, Meta, MetaFunction, + Navigate, Outlet, Scripts, ScrollRestoration, + useLocation, } from "react-router"; import "./tailwind.css"; import "./index.css"; @@ -16,6 +18,7 @@ import { TelemetryConsentBanner } from "#/components/features/analytics/telemetr import { LoadingSpinner } from "#/components/shared/loading-spinner"; import { useConfig } from "#/hooks/query/use-config"; import { AgentServerUIRoot } from "#/components/providers"; +import { AgentServerConnectionScreen } from "#/components/features/settings/agent-server-onboarding"; import { applyColorTheme, readPersistedColorTheme, @@ -29,13 +32,7 @@ function ColorThemeApplier() { return null; } -// Only rendered when the active backend is unreachable; keep the modal out of -// the default root graph. -const ManageBackendsModal = React.lazy(() => - import("#/components/features/backends/manage-backends-modal").then((m) => ({ - default: m.ManageBackendsModal, - })), -); +const AGENT_SERVER_SETTINGS_PATH = "/settings/backend"; export function Layout({ children }: { children: React.ReactNode }) { return ( @@ -51,7 +48,6 @@ export function Layout({ children }: { children: React.ReactNode }) { {children} -