diff --git a/__tests__/api/agent-server-adapter.test.ts b/__tests__/api/agent-server-adapter.test.ts index 2f6ad67b7..a1762feb6 100644 --- a/__tests__/api/agent-server-adapter.test.ts +++ b/__tests__/api/agent-server-adapter.test.ts @@ -32,6 +32,9 @@ const { vi.mock("#/api/agent-server-config", () => ({ getAgentServerBaseUrl: vi.fn(() => "http://127.0.0.1:8000"), + resolveBrowserReachableAgentServerBaseUrl: vi.fn((baseUrl: string) => + baseUrl.replace(/\/+$/, ""), + ), getAgentServerSessionApiKey: vi.fn(() => null), getAgentServerWorkingDir: mockGetAgentServerWorkingDir, getConfiguredWorkerUrls: vi.fn(() => []), diff --git a/__tests__/api/agent-server-client-options.test.ts b/__tests__/api/agent-server-client-options.test.ts new file mode 100644 index 000000000..38c671446 --- /dev/null +++ b/__tests__/api/agent-server-client-options.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { getAgentServerClientOptions } from "#/api/agent-server-client-options"; +import { + __resetActiveStoreForTests, + setActiveSelection, + setRegisteredBackends, +} from "#/api/backend-registry/active-store"; +import type { Backend } from "#/api/backend-registry/types"; + +const ORIGINAL_LOCATION = window.location; + +function mockWindowLocation(url: string) { + Object.defineProperty(window, "location", { + configurable: true, + value: new URL(url), + }); +} + +beforeEach(() => { + window.localStorage.clear(); + __resetActiveStoreForTests(); +}); + +afterEach(() => { + window.localStorage.clear(); + __resetActiveStoreForTests(); + Object.defineProperty(window, "location", { + configurable: true, + value: ORIGINAL_LOCATION, + }); +}); + +describe("agent server client options", () => { + it("routes stored loopback local backends through the browser origin for proxy-capable runtime browsers", () => { + mockWindowLocation( + "https://work-1-abc.prod-runtime.all-hands.dev/conversations", + ); + const backend: Backend = { + id: "local-1", + name: "Local", + host: "http://127.0.0.1:18000", + apiKey: "session-key", + kind: "local", + }; + setRegisteredBackends([backend]); + setActiveSelection({ backendId: backend.id, orgId: null }); + + expect(getAgentServerClientOptions()).toMatchObject({ + host: "https://work-1-abc.prod-runtime.all-hands.dev", + apiKey: "session-key", + }); + }); + + it("preserves stored loopback local backends for separately hosted frontends", () => { + mockWindowLocation("https://agent-canvas.example.com/conversations"); + const backend: Backend = { + id: "local-1", + name: "Local", + host: "http://127.0.0.1:18000", + apiKey: "session-key", + kind: "local", + }; + setRegisteredBackends([backend]); + setActiveSelection({ backendId: backend.id, orgId: null }); + + expect(getAgentServerClientOptions()).toMatchObject({ + host: "http://127.0.0.1:18000", + apiKey: "session-key", + }); + }); + + it("preserves non-loopback local backend hosts", () => { + mockWindowLocation("https://work-1.example.dev/conversations"); + const backend: Backend = { + id: "local-1", + name: "Local", + host: "https://agent.example.com/", + apiKey: "session-key", + kind: "local", + }; + setRegisteredBackends([backend]); + setActiveSelection({ backendId: backend.id, orgId: null }); + + expect(getAgentServerClientOptions()).toMatchObject({ + host: "https://agent.example.com", + apiKey: "session-key", + }); + }); +}); diff --git a/__tests__/api/agent-server-config.test.ts b/__tests__/api/agent-server-config.test.ts index d2fdd6b28..986be921b 100644 --- a/__tests__/api/agent-server-config.test.ts +++ b/__tests__/api/agent-server-config.test.ts @@ -7,6 +7,7 @@ import { getAgentServerFormDefaults, getAgentServerSessionApiKey, getAgentServerWorkingDir, + resolveBrowserReachableAgentServerBaseUrl, saveAgentServerConfig, shouldLoadPublicSkills, } from "#/api/agent-server-config"; @@ -30,14 +31,28 @@ afterEach(() => { }); describe("agent server config", () => { - it("uses the browser origin when a remote browser is pointed at localhost backend config", () => { - mockWindowLocation("https://work-1.example.dev/settings"); + it("uses the browser origin when a proxy-capable runtime browser is pointed at localhost backend config", () => { + mockWindowLocation( + "https://work-1-abc.prod-runtime.all-hands.dev/settings", + ); window.localStorage.setItem( AGENT_SERVER_CONFIG_STORAGE_KEY, JSON.stringify({ baseUrl: "http://127.0.0.1:8000" }), ); - expect(getAgentServerBaseUrl()).toBe("https://work-1.example.dev"); + expect(getAgentServerBaseUrl()).toBe( + "https://work-1-abc.prod-runtime.all-hands.dev", + ); + }); + + it("preserves loopback backend URLs for separately hosted frontends", () => { + mockWindowLocation("https://agent-canvas.example.com/settings"); + window.localStorage.setItem( + AGENT_SERVER_CONFIG_STORAGE_KEY, + JSON.stringify({ baseUrl: "http://127.0.0.1:8000" }), + ); + + expect(getAgentServerBaseUrl()).toBe("http://127.0.0.1:8000"); }); it("preserves a non-local backend URL from stored config", () => { @@ -50,6 +65,16 @@ describe("agent server config", () => { expect(getAgentServerBaseUrl()).toBe("https://agent.example.com"); }); + it("resolves stored loopback backend hosts through the browser origin for proxy-capable runtime browsers", () => { + mockWindowLocation( + "https://work-1-abc.prod-runtime.all-hands.dev/conversations", + ); + + expect( + resolveBrowserReachableAgentServerBaseUrl("http://localhost:18000"), + ).toBe("https://work-1-abc.prod-runtime.all-hands.dev"); + }); + it("prefills the settings form from environment defaults when local settings are empty", () => { vi.stubEnv("VITE_BACKEND_BASE_URL", "https://env-agent.example.com/"); vi.stubEnv("VITE_SESSION_API_KEY", "env-session-key"); diff --git a/__tests__/api/agent-server-conversation-service.test.ts b/__tests__/api/agent-server-conversation-service.test.ts index 04772e881..0580706d8 100644 --- a/__tests__/api/agent-server-conversation-service.test.ts +++ b/__tests__/api/agent-server-conversation-service.test.ts @@ -74,6 +74,9 @@ vi.mock("@openhands/typescript-client/clients", async () => { vi.mock("#/api/agent-server-config", () => ({ DEFAULT_WORKING_DIR: "workspace/project", getAgentServerBaseUrl: vi.fn(() => "http://localhost:54928"), + resolveBrowserReachableAgentServerBaseUrl: vi.fn((baseUrl: string) => + baseUrl.replace(/\/+$/, ""), + ), getAgentServerSessionApiKey: vi.fn(() => "test-api-key"), getAgentServerWorkingDir: vi.fn(() => "/workspace/project/agent-canvas"), buildConversationWorkingDir: vi.fn( diff --git a/__tests__/api/cloud/proxy.test.ts b/__tests__/api/cloud/proxy.test.ts index 56f1512e8..33e78c99c 100644 --- a/__tests__/api/cloud/proxy.test.ts +++ b/__tests__/api/cloud/proxy.test.ts @@ -10,6 +10,15 @@ import type { Backend } from "#/api/backend-registry/types"; vi.mock("axios"); +const ORIGINAL_LOCATION = window.location; + +function mockWindowLocation(url: string) { + Object.defineProperty(window, "location", { + configurable: true, + value: new URL(url), + }); +} + const localBackend: Backend = { id: "local-1", name: "Local", @@ -45,6 +54,10 @@ afterEach(() => { window.localStorage.clear(); __resetActiveStoreForTests(); vi.mocked(axios.post).mockReset(); + Object.defineProperty(window, "location", { + configurable: true, + value: ORIGINAL_LOCATION, + }); }); describe("callCloudProxy X-Org-Id injection", () => { @@ -98,4 +111,50 @@ describe("callCloudProxy X-Org-Id injection", () => { (body as { headers: Record }).headers, ).not.toHaveProperty("X-Org-Id"); }); + + it("posts through the browser origin when the stored local backend is loopback-only on a proxy-capable runtime host", async () => { + mockWindowLocation( + "https://work-1-abc.prod-runtime.all-hands.dev/conversations", + ); + setRegisteredBackends([ + { ...localBackend, host: "http://127.0.0.1:18000" }, + cloudPersonal, + ]); + setActiveSelection({ + backendId: cloudPersonal.id, + orgId: null, + }); + + await callCloudProxy({ + backend: cloudPersonal, + method: "GET", + path: "/api/v1/app-conversations/search", + }); + + const [url] = vi.mocked(axios.post).mock.calls[0]!; + expect(url).toBe( + "https://work-1-abc.prod-runtime.all-hands.dev/api/cloud-proxy", + ); + }); + + it("posts to loopback for separately hosted frontends with a local backend", async () => { + mockWindowLocation("https://agent-canvas.example.com/conversations"); + setRegisteredBackends([ + { ...localBackend, host: "http://127.0.0.1:18000" }, + cloudPersonal, + ]); + setActiveSelection({ + backendId: cloudPersonal.id, + orgId: null, + }); + + await callCloudProxy({ + backend: cloudPersonal, + method: "GET", + path: "/api/v1/app-conversations/search", + }); + + const [url] = vi.mocked(axios.post).mock.calls[0]!; + expect(url).toBe("http://127.0.0.1:18000/api/cloud-proxy"); + }); }); diff --git a/__tests__/api/device-flow-client.test.ts b/__tests__/api/device-flow-client.test.ts index 806b21d26..046e0604d 100644 --- a/__tests__/api/device-flow-client.test.ts +++ b/__tests__/api/device-flow-client.test.ts @@ -19,6 +19,14 @@ vi.mock("../../src/api/backend-registry/auth", () => ({ })); const TEST_HOST_URL = "https://app.all-hands.dev"; +const ORIGINAL_LOCATION = window.location; + +function mockWindowLocation(url: string) { + Object.defineProperty(window, "location", { + configurable: true, + value: new URL(url), + }); +} describe("device-flow-client", () => { beforeEach(() => { @@ -28,6 +36,10 @@ describe("device-flow-client", () => { afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); + Object.defineProperty(window, "location", { + configurable: true, + value: ORIGINAL_LOCATION, + }); }); describe("isOpenHandsCloudHost", () => { @@ -126,6 +138,54 @@ describe("device-flow-client", () => { expect(body.host).toBe(TEST_HOST_URL); }); + it("uses the browser origin for the local proxy when a proxy-capable runtime browser has a loopback backend", async () => { + mockWindowLocation( + "https://work-1-abc.prod-runtime.all-hands.dev/settings", + ); + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + device_code: "dc", + user_code: "uc", + verification_uri: "v", + verification_uri_complete: "vc", + expires_in: 600, + interval: 5, + }), + }); + + await startDeviceFlow(TEST_HOST_URL); + + expect(fetch).toHaveBeenCalledWith( + "https://work-1-abc.prod-runtime.all-hands.dev/api/cloud-proxy", + expect.any(Object), + ); + }); + + it("uses loopback for the local proxy on separately hosted frontends", async () => { + mockWindowLocation("https://agent-canvas.example.com/settings"); + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + device_code: "dc", + user_code: "uc", + verification_uri: "v", + verification_uri_complete: "vc", + expires_in: 600, + interval: 5, + }), + }); + + await startDeviceFlow(TEST_HOST_URL); + + expect(fetch).toHaveBeenCalledWith( + "http://localhost:18000/api/cloud-proxy", + expect.any(Object), + ); + }); + it("throws DeviceFlowError on HTTP error", async () => { global.fetch = vi.fn().mockResolvedValue({ ok: false, diff --git a/__tests__/api/use-create-conversation-metadata.test.ts b/__tests__/api/use-create-conversation-metadata.test.ts index 029e11aec..4dc77b656 100644 --- a/__tests__/api/use-create-conversation-metadata.test.ts +++ b/__tests__/api/use-create-conversation-metadata.test.ts @@ -44,6 +44,9 @@ vi.mock("@openhands/typescript-client/clients", async () => { vi.mock("#/api/agent-server-config", () => ({ DEFAULT_WORKING_DIR: "workspace/project", getAgentServerBaseUrl: vi.fn(() => "http://localhost:54928"), + resolveBrowserReachableAgentServerBaseUrl: vi.fn((baseUrl: string) => + baseUrl.replace(/\/+$/, ""), + ), getAgentServerSessionApiKey: vi.fn(() => null), getAgentServerWorkingDir: vi.fn(() => "/workspace/project/agent-canvas"), buildConversationWorkingDir: vi.fn( diff --git a/src/api/agent-server-client-options.ts b/src/api/agent-server-client-options.ts index c594fcaff..f1d42b83e 100644 --- a/src/api/agent-server-client-options.ts +++ b/src/api/agent-server-client-options.ts @@ -2,6 +2,7 @@ import { buildHttpBaseUrl } from "#/utils/websocket-url"; import { getAgentServerSessionApiKey, getAgentServerWorkingDir, + resolveBrowserReachableAgentServerBaseUrl, } from "./agent-server-config"; import { getEffectiveLocalBackend } from "./backend-registry/active-store"; import { DEFAULT_LOCAL_BACKEND_ID } from "./backend-registry/default-backend"; @@ -24,7 +25,7 @@ export interface AgentServerClientOptions { } function normalizeHost(host: string): string { - return host.replace(/\/+$/, ""); + return resolveBrowserReachableAgentServerBaseUrl(host).replace(/\/+$/, ""); } function resolveHost( diff --git a/src/api/agent-server-config.ts b/src/api/agent-server-config.ts index 9a7ab02d9..490f90fa0 100644 --- a/src/api/agent-server-config.ts +++ b/src/api/agent-server-config.ts @@ -85,6 +85,35 @@ function getConfiguredSessionApiKey(): string | null { return trimToNull(import.meta.env.VITE_SESSION_API_KEY); } +const LOOPBACK_HOSTNAMES = new Set([ + "127.0.0.1", + "localhost", + "0.0.0.0", + "::1", + "[::1]", +]); + +// Only origins known to proxy /api and /sockets back into the same runtime +// should rewrite loopback backends. Generic hosted frontends must preserve +// loopback because it may intentionally point at the user's local machine. +const LOOPBACK_PROXY_HOST_SUFFIXES = [ + ".prod-runtime.all-hands.dev", + ".staging-runtime.all-hands.dev", +]; + +function isLoopbackHostname(hostname: string): boolean { + return LOOPBACK_HOSTNAMES.has(hostname.toLowerCase()); +} + +function browserOriginCanProxyLoopback(): boolean { + const browserHostname = window.location.hostname.toLowerCase(); + + return LOOPBACK_PROXY_HOST_SUFFIXES.some( + (suffix) => + browserHostname === suffix.slice(1) || browserHostname.endsWith(suffix), + ); +} + function shouldUseProxyOrigin(baseUrl: string): boolean { if (typeof window === "undefined") { return false; @@ -92,11 +121,12 @@ function shouldUseProxyOrigin(baseUrl: string): boolean { try { const configuredUrl = new URL(baseUrl); - const localHosts = new Set(["127.0.0.1", "localhost", "0.0.0.0"]); const browserHostname = window.location.hostname; return ( - localHosts.has(configuredUrl.hostname) && !localHosts.has(browserHostname) + isLoopbackHostname(configuredUrl.hostname) && + !isLoopbackHostname(browserHostname) && + browserOriginCanProxyLoopback() ); } catch { return false; @@ -108,11 +138,20 @@ function resolveAgentServerBaseUrl(baseUrl: string | null): string | null { return null; } - if (shouldUseProxyOrigin(baseUrl)) { + return resolveBrowserReachableAgentServerBaseUrl(baseUrl); +} + +export function resolveBrowserReachableAgentServerBaseUrl( + baseUrl: string, +): string { + const normalizedUrl = + normalizeBaseUrl(baseUrl) ?? baseUrl.replace(/\/+$/, ""); + + if (shouldUseProxyOrigin(normalizedUrl)) { return window.location.origin; } - return baseUrl; + return normalizedUrl; } export function getAgentServerFormDefaults(): AgentServerFormDefaults { diff --git a/src/api/automation-service/automation-service.api.ts b/src/api/automation-service/automation-service.api.ts index b8c63842f..e1e96b822 100644 --- a/src/api/automation-service/automation-service.api.ts +++ b/src/api/automation-service/automation-service.api.ts @@ -10,6 +10,7 @@ import { getEffectiveLocalBackend, } from "../backend-registry/active-store"; import { callCloudProxy } from "../cloud/proxy"; +import { resolveBrowserReachableAgentServerBaseUrl } from "../agent-server-config"; const AUTOMATION_BASE_PATH = "/api/automation"; @@ -30,8 +31,12 @@ localAutomationAxios.interceptors.request.use((config) => { // currently-active local backend (and any host edits made via the // manage-backends UI), rather than freezing whatever value the // agent-server-config produced at module load time. - // eslint-disable-next-line no-param-reassign - if (!config.baseURL) config.baseURL = getEffectiveLocalBackend().host; + if (!config.baseURL) { + // eslint-disable-next-line no-param-reassign + config.baseURL = resolveBrowserReachableAgentServerBaseUrl( + getEffectiveLocalBackend().host, + ); + } const apiKey = import.meta.env.VITE_AUTOMATION_API_KEY?.trim(); if (apiKey) { diff --git a/src/api/cloud/proxy.ts b/src/api/cloud/proxy.ts index d21e2c0e2..415bda6a8 100644 --- a/src/api/cloud/proxy.ts +++ b/src/api/cloud/proxy.ts @@ -3,7 +3,10 @@ import { getActiveBackend, getEffectiveLocalBackend, } from "../backend-registry/active-store"; -import { getAgentServerHeaders } from "../agent-server-config"; +import { + getAgentServerHeaders, + resolveBrowserReachableAgentServerBaseUrl, +} from "../agent-server-config"; import { buildAuthHeaders } from "../backend-registry/auth"; import type { Backend } from "../backend-registry/types"; @@ -74,6 +77,9 @@ export async function callCloudProxy( req: CloudProxyRequest, ): Promise { const local = getEffectiveLocalBackend(); + const localHost = resolveBrowserReachableAgentServerBaseUrl( + local.host, + ).replace(/\/+$/, ""); const localAuthHeaders = { ...buildAuthHeaders(local), ...getAgentServerHeaders(), @@ -102,7 +108,7 @@ export async function callCloudProxy( // 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}/api/cloud-proxy`, { host: upstreamHost, method: req.method, diff --git a/src/api/device-flow-client.ts b/src/api/device-flow-client.ts index 7a777ad00..5d73f7806 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 { resolveBrowserReachableAgentServerBaseUrl } from "./agent-server-config"; export class DeviceFlowError extends Error { constructor( @@ -86,7 +87,9 @@ async function makeProxiedRequest( signal?: AbortSignal, ): Promise { const local = getEffectiveLocalBackend(); - const proxyUrl = `${local.host.replace(/\/+$/, "")}/api/cloud-proxy`; + const proxyUrl = `${resolveBrowserReachableAgentServerBaseUrl( + local.host, + ).replace(/\/+$/, "")}/api/cloud-proxy`; const response = await fetch(proxyUrl, { method: "POST",