diff --git a/__tests__/api/cloud/sandbox-service.test.ts b/__tests__/api/cloud/sandbox-service.test.ts new file mode 100644 index 0000000000..c46478e61a --- /dev/null +++ b/__tests__/api/cloud/sandbox-service.test.ts @@ -0,0 +1,56 @@ +import axios from "axios"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + __resetActiveStoreForTests, + setActiveSelection, + setRegisteredBackends, +} from "#/api/backend-registry/active-store"; +import { batchGetCloudSandboxes } from "#/api/cloud/sandbox-service.api"; +import type { Backend } from "#/api/backend-registry/types"; + +vi.mock("axios"); + +const cloudBackend: Backend = { + id: "cloud-prod", + name: "Production", + host: "https://app.all-hands.dev", + apiKey: "bearer-token", + kind: "cloud", +}; + +beforeEach(() => { + window.localStorage.clear(); + __resetActiveStoreForTests(); + setRegisteredBackends([cloudBackend]); + setActiveSelection({ backendId: cloudBackend.id }); + vi.mocked(axios.post).mockReset(); + vi.mocked(axios.post).mockResolvedValue({ data: [] }); +}); + +afterEach(() => { + window.localStorage.clear(); + __resetActiveStoreForTests(); + vi.mocked(axios.post).mockReset(); +}); + +describe("batchGetCloudSandboxes", () => { + it("targets /api/v1/sandboxes with one id query param per sandbox id", async () => { + // Arrange — multiple ids exercises the URLSearchParams.append path, + // which is the SaaS contract for batch-fetching sandboxes (the GUI + // reads sandbox.exposed_urls from the response to find the VSCODE + // URL instead of asking the runtime for a localhost address). + const ids = ["sandbox-a", "sandbox-b"]; + + // Act + await batchGetCloudSandboxes(ids); + + // Assert — the cloud-proxy envelope encodes a GET against + // /api/v1/sandboxes?id=sandbox-a&id=sandbox-b on the SaaS. + const [, body] = vi.mocked(axios.post).mock.calls[0]!; + const upstream = body as { method: string; path: string }; + expect(upstream.method).toBe("GET"); + expect(upstream.path).toBe( + "/api/v1/sandboxes?id=sandbox-a&id=sandbox-b", + ); + }); +}); diff --git a/__tests__/hooks/use-unified-vscode-url.test.tsx b/__tests__/hooks/use-unified-vscode-url.test.tsx new file mode 100644 index 0000000000..eae7ab0ebc --- /dev/null +++ b/__tests__/hooks/use-unified-vscode-url.test.tsx @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { I18nextProvider } from "react-i18next"; +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import React from "react"; +import { useUnifiedVSCodeUrl } from "#/hooks/query/use-unified-vscode-url"; +import { batchGetCloudSandboxes } from "#/api/cloud/sandbox-service.api"; +import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; +import ConversationService from "#/api/conversation-service/conversation-service.api"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready"; +import type { ResolvedActiveBackend } from "#/api/backend-registry/types"; +import type { V1SandboxInfo } from "#/api/cloud/sandbox-service.types"; +import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; + +vi.mock("#/api/cloud/sandbox-service.api"); +vi.mock("#/api/conversation-service/agent-server-conversation-service.api"); +vi.mock("#/api/conversation-service/conversation-service.api"); +vi.mock("#/contexts/active-backend-context"); +vi.mock("#/hooks/query/use-active-conversation"); +vi.mock("#/hooks/use-runtime-is-ready"); +vi.mock("#/hooks/use-conversation-id", () => ({ + useConversationId: () => ({ conversationId: "conv-123" }), +})); + +if (!i18n.isInitialized) { + i18n.use(initReactI18next).init({ + lng: "en", + fallbackLng: "en", + ns: ["openhands"], + defaultNS: "openhands", + resources: { en: { openhands: {} } }, + interpolation: { escapeValue: false }, + returnEmptyString: false, + }); +} + +const cloudBackend: ResolvedActiveBackend = { + backend: { + id: "cloud-prod", + name: "Production", + host: "https://app.all-hands.dev", + apiKey: "key", + kind: "cloud", + }, + orgId: "org-1", +}; + +const localBackend: ResolvedActiveBackend = { + backend: { + id: "local-1", + name: "Local", + host: "http://localhost:8000", + apiKey: "key", + kind: "local", + }, + orgId: null, +}; + +function makeConversation( + overrides: Partial = {}, +): AppConversation { + return { + id: "conv-123", + sandbox_id: "sandbox-9", + conversation_url: "http://abc.staging-runtime.all-hands.dev/api/conv/1", + session_api_key: "sek", + created_by_user_id: null, + selected_repository: null, + selected_branch: null, + git_provider: null, + title: null, + trigger: null, + pr_number: [], + llm_model: null, + metrics: null, + created_at: "2026-05-12T00:00:00Z", + updated_at: "2026-05-12T00:00:00Z", + execution_status: "running", + sub_conversation_ids: [], + ...overrides, + } as AppConversation; +} + +function makeSandbox( + overrides: Partial = {}, +): V1SandboxInfo { + return { + id: "sandbox-9", + created_by_user_id: null, + sandbox_spec_id: "spec-1", + status: "RUNNING", + session_api_key: "sek", + exposed_urls: [ + { + name: "VSCODE", + url: "https://vscode-abc.staging-runtime.all-hands.dev/?tkn=sek&folder=%2Fworkspace%2Fproject", + }, + ], + created_at: "2026-05-12T00:00:00Z", + ...overrides, + }; +} + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useRuntimeIsReady).mockReturnValue(true); + vi.mocked(useActiveConversation).mockReturnValue({ + data: makeConversation(), + } as unknown as ReturnType); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("useUnifiedVSCodeUrl", () => { + it("returns the SaaS-computed VSCode URL from sandbox.exposed_urls in cloud mode", async () => { + // Arrange — cloud backend, sandbox returned with a VSCODE entry. + // This is the steady-state happy path: the SaaS pre-builds the + // public vscode subdomain URL and the GUI must surface it directly + // instead of asking the runtime for /api/vscode/url (which only + // knows its own localhost:8001). + vi.mocked(useActiveBackend).mockReturnValue(cloudBackend); + vi.mocked(batchGetCloudSandboxes).mockResolvedValue([makeSandbox()]); + + // Act + const { result } = renderHook(() => useUnifiedVSCodeUrl(), { + wrapper: createWrapper(), + }); + + // Assert + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.url).toBe( + "https://vscode-abc.staging-runtime.all-hands.dev/?tkn=sek&folder=%2Fworkspace%2Fproject", + ); + expect( + AgentServerConversationService.getVSCodeUrl, + ).not.toHaveBeenCalled(); + }); + + it("returns null url in cloud mode when the sandbox has no VSCODE exposed_url", async () => { + // Arrange — sandbox is reachable but isn't running yet (STARTING / + // PAUSED), so exposed_urls hasn't been populated. The hook must + // surface "no URL" gracefully so the tab shows the empty-state + // copy instead of crashing or serving a localhost fallback. + vi.mocked(useActiveBackend).mockReturnValue(cloudBackend); + vi.mocked(batchGetCloudSandboxes).mockResolvedValue([ + makeSandbox({ status: "STARTING", exposed_urls: null }), + ]); + + // Act + const { result } = renderHook(() => useUnifiedVSCodeUrl(), { + wrapper: createWrapper(), + }); + + // Assert + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.url).toBeNull(); + }); + + it("falls through to AgentServerConversationService.getVSCodeUrl in local mode", async () => { + // Arrange — local backend: cloud sandbox lookup must be skipped and + // the existing local resolver must drive the URL. Regression check + // for the cloud/local branch that was added to the hook. + vi.mocked(useActiveBackend).mockReturnValue(localBackend); + vi.mocked(AgentServerConversationService.getVSCodeUrl).mockResolvedValue({ + vscode_url: "http://localhost:8001/?tkn=local-key&folder=workspace", + }); + + // Act + const { result } = renderHook(() => useUnifiedVSCodeUrl(), { + wrapper: createWrapper(), + }); + + // Assert + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect( + AgentServerConversationService.getVSCodeUrl, + ).toHaveBeenCalledWith( + "conv-123", + "http://abc.staging-runtime.all-hands.dev/api/conv/1", + "sek", + ); + expect(batchGetCloudSandboxes).not.toHaveBeenCalled(); + expect(ConversationService.getVSCodeUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/src/api/cloud/sandbox-service.api.ts b/src/api/cloud/sandbox-service.api.ts new file mode 100644 index 0000000000..98bcd52b20 --- /dev/null +++ b/src/api/cloud/sandbox-service.api.ts @@ -0,0 +1,39 @@ +import { getActiveBackend } from "../backend-registry/active-store"; +import type { Backend } from "../backend-registry/types"; +import { callCloudProxy } from "./proxy"; +import type { V1SandboxInfo } from "./sandbox-service.types"; + +function getActiveCloudBackend(): Backend { + const active = getActiveBackend().backend; + if (active.kind !== "cloud") { + throw new Error("Cloud sandboxes call requires a cloud backend."); + } + return active; +} + +/** + * Batch-fetch cloud sandboxes by id. Mirrors OpenHands' + * `SandboxService.batchGetSandboxes` — routes through the bundled + * agent-server's cloud proxy and hits `GET /api/v1/sandboxes?id=...` on + * the SaaS, returning each `SandboxInfo` (or null if not found). + * + * The returned `SandboxInfo.exposed_urls` carry the SaaS-computed, + * publicly-reachable URLs for the sandbox's services (VSCODE, + * AGENT_SERVER, WORKER_*) — the GUI reads them directly instead of + * asking the runtime for `/api/vscode/url`, which only knows its + * internal localhost address. + */ +export async function batchGetCloudSandboxes( + ids: string[], +): Promise<(V1SandboxInfo | null)[]> { + if (ids.length === 0) return []; + const backend = getActiveCloudBackend(); + const params = new URLSearchParams(); + for (const id of ids) params.append("id", id); + const data = await callCloudProxy<(V1SandboxInfo | null)[]>({ + backend, + method: "GET", + path: `/api/v1/sandboxes?${params.toString()}`, + }); + return data ?? []; +} diff --git a/src/api/cloud/sandbox-service.types.ts b/src/api/cloud/sandbox-service.types.ts new file mode 100644 index 0000000000..a4848951bb --- /dev/null +++ b/src/api/cloud/sandbox-service.types.ts @@ -0,0 +1,21 @@ +export type V1SandboxStatus = + | "STARTING" + | "RUNNING" + | "PAUSED" + | "ERROR" + | "MISSING"; + +export interface V1ExposedUrl { + name: string; + url: string; +} + +export interface V1SandboxInfo { + id: string; + created_by_user_id: string | null; + sandbox_spec_id: string; + status: V1SandboxStatus; + session_api_key: string | null; + exposed_urls: V1ExposedUrl[] | null; + created_at: string; +} diff --git a/src/api/conversation-service/agent-server-conversation-service.api.ts b/src/api/conversation-service/agent-server-conversation-service.api.ts index d775bab365..9b75b82492 100644 --- a/src/api/conversation-service/agent-server-conversation-service.api.ts +++ b/src/api/conversation-service/agent-server-conversation-service.api.ts @@ -186,28 +186,13 @@ class AgentServerConversationService { conversationUrl: string | null | undefined, sessionApiKey?: string | null, ): Promise { - const active = getActiveBackend().backend; - - // Cloud mode: route through the cloud-proxy to the runtime sandbox. - // The runtime exposes a SaaS-style endpoint at `/api/vscode/url` - // that returns `{ url }`; we map it back to `{ vscode_url }` to - // match the local response shape. - if (active.kind === "cloud" && conversationUrl) { - const data = await callCloudProxy<{ url: string | null }>({ - backend: active, - method: "GET", - hostOverride: buildHttpBaseUrl(conversationUrl), - path: "/api/vscode/url", - authMode: "session-api-key", - sessionApiKey, - }); - return { vscode_url: data?.url ?? null }; - } - + // Local-only path. Cloud conversations read the VSCode URL straight + // from the SaaS-computed `sandbox.exposed_urls` (see + // `useUnifiedVSCodeUrl` + `useCloudSandbox`); the runtime's own + // `/api/vscode/url` only knows its internal `localhost:8001`, which + // the user's browser can't reach. const workspaceDir = await this.resolveConversationWorkingDir(conversationId); - // Local mode: the typescript-client targets the local agent-server - // directly via the conversationUrl override. const vscodeUrl = await createVSCodeClient({ conversationUrl, sessionApiKey, diff --git a/src/hooks/query/use-cloud-sandbox.ts b/src/hooks/query/use-cloud-sandbox.ts new file mode 100644 index 0000000000..0841a2e2ab --- /dev/null +++ b/src/hooks/query/use-cloud-sandbox.ts @@ -0,0 +1,20 @@ +import { useQuery } from "@tanstack/react-query"; +import { batchGetCloudSandboxes } from "#/api/cloud/sandbox-service.api"; +import { useActiveBackend } from "#/contexts/active-backend-context"; + +export const useCloudSandbox = (sandboxId: string | null | undefined) => { + const active = useActiveBackend(); + const isCloud = active.backend.kind === "cloud"; + + return useQuery({ + queryKey: ["cloud", "sandbox", active.backend.id, active.orgId, sandboxId], + queryFn: async () => { + if (!sandboxId) return null; + const [sandbox] = await batchGetCloudSandboxes([sandboxId]); + return sandbox ?? null; + }, + enabled: isCloud && !!sandboxId, + staleTime: 1000 * 60 * 5, + gcTime: 1000 * 60 * 15, + }); +}; diff --git a/src/hooks/query/use-unified-vscode-url.ts b/src/hooks/query/use-unified-vscode-url.ts index a8298eec02..b84f1c0d68 100644 --- a/src/hooks/query/use-unified-vscode-url.ts +++ b/src/hooks/query/use-unified-vscode-url.ts @@ -7,27 +7,40 @@ import AgentServerConversationService from "#/api/conversation-service/agent-ser import { transformVSCodeUrl } from "#/utils/vscode-url-helper"; import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready"; import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import { useCloudSandbox } from "#/hooks/query/use-cloud-sandbox"; interface VSCodeUrlResult { url: string | null; } +const VSCODE_EXPOSED_URL_NAME = "VSCODE"; + export const useUnifiedVSCodeUrl = () => { const { t } = useTranslation("openhands"); const { conversationId } = useConversationId(); const runtimeIsReady = useRuntimeIsReady({ allowAgentError: true }); const { data: conversation } = useActiveConversation(); + const active = useActiveBackend(); const conversationUrl = conversation?.conversation_url ?? null; const sessionApiKey = conversation?.session_api_key ?? null; + const sandboxId = conversation?.sandbox_id ?? null; + const isCloud = active.backend.kind === "cloud"; - const mainQuery = useQuery({ + // Cloud mode: read VSCode URL from the SaaS-computed `exposed_urls` on + // the conversation's sandbox. The runtime's `/api/vscode/url` only + // knows its internal `localhost:8001`, so calling it returned a URL + // the user's browser couldn't reach. + const cloudSandboxQuery = useCloudSandbox(isCloud ? sandboxId : null); + + const localQuery = useQuery({ // Include conversation host + key in the cache key so different - // conversations don't share VSCode URL data, and so a cloud-→-local - // (or vice versa) swap re-fetches against the right host. + // conversations don't share VSCode URL data. queryKey: [ "unified", "vscode_url", + "local", conversationId, conversationUrl, sessionApiKey, @@ -35,9 +48,6 @@ export const useUnifiedVSCodeUrl = () => { queryFn: async () => { if (!conversationId) throw new Error("No conversation ID"); - // Forward the conversation's owning host + session key so cloud - // conversations hit their cloud sandbox rather than falling back - // to the bundled local agent-server. const response = await AgentServerConversationService.getVSCodeUrl( conversationId, conversationUrl, @@ -46,25 +56,69 @@ export const useUnifiedVSCodeUrl = () => { return { url: transformVSCodeUrl(response.vscode_url) }; }, - enabled: runtimeIsReady && !!conversationId, + enabled: !isCloud && runtimeIsReady && !!conversationId, refetchOnMount: true, retry: 3, }); + let data: VSCodeUrlResult | undefined; + let isLoading: boolean; + let isError: boolean; + let isSuccess: boolean; + let status: typeof localQuery.status; + let error: unknown; + let refetch: () => Promise<{ data: VSCodeUrlResult | undefined }>; + + if (isCloud) { + const sandbox = cloudSandboxQuery.data; + const exposedUrl = + sandbox?.exposed_urls?.find((u) => u.name === VSCODE_EXPOSED_URL_NAME) + ?.url ?? null; + data = cloudSandboxQuery.isSuccess + ? { url: transformVSCodeUrl(exposedUrl) } + : undefined; + isLoading = cloudSandboxQuery.isLoading; + isError = cloudSandboxQuery.isError; + isSuccess = cloudSandboxQuery.isSuccess; + status = cloudSandboxQuery.status; + error = cloudSandboxQuery.error; + refetch = async () => { + const result = await cloudSandboxQuery.refetch(); + const refreshedUrl = + result.data?.exposed_urls?.find( + (u) => u.name === VSCODE_EXPOSED_URL_NAME, + )?.url ?? null; + return { + data: result.data + ? { url: transformVSCodeUrl(refreshedUrl) } + : undefined, + }; + }; + } else { + data = localQuery.data; + isLoading = localQuery.isLoading; + isError = localQuery.isError; + isSuccess = localQuery.isSuccess; + status = localQuery.status; + error = localQuery.error; + refetch = async () => { + const result = await localQuery.refetch(); + return { data: result.data }; + }; + } + // Derive the i18n'd "URL unavailable" message outside `queryFn` so the // queryKey doesn't have to include `t`. - const error = - mainQuery.data && !mainQuery.data.url - ? t(I18nKey.VSCODE$URL_NOT_AVAILABLE) - : null; + const errorMessage = + data && !data.url ? t(I18nKey.VSCODE$URL_NOT_AVAILABLE) : null; return { - data: mainQuery.data ? { ...mainQuery.data, error } : undefined, - error: mainQuery.error, - isLoading: mainQuery.isLoading, - isError: mainQuery.isError, - isSuccess: mainQuery.isSuccess, - status: mainQuery.status, - refetch: mainQuery.refetch, + data: data ? { ...data, error: errorMessage } : undefined, + error, + isLoading, + isError, + isSuccess, + status, + refetch, }; };