fix(frontend): read cloud VSCode URL from sandbox.exposed_urls (#361)

* fix: read cloud VSCode URL from sandbox.exposed_urls

* fix: lint
This commit is contained in:
Hiep Le
2026-05-12 15:57:05 +07:00
committed by GitHub
parent 6bb94b8bf0
commit 801c166074
7 changed files with 415 additions and 38 deletions
@@ -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",
);
});
});
@@ -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> = {},
): 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> = {},
): 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 }) => (
<QueryClientProvider client={queryClient}>
<I18nextProvider i18n={i18n}>{children}</I18nextProvider>
</QueryClientProvider>
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useRuntimeIsReady).mockReturnValue(true);
vi.mocked(useActiveConversation).mockReturnValue({
data: makeConversation(),
} as unknown as ReturnType<typeof useActiveConversation>);
});
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();
});
});
+39
View File
@@ -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 ?? [];
}
+21
View File
@@ -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;
}
@@ -186,28 +186,13 @@ class AgentServerConversationService {
conversationUrl: string | null | undefined,
sessionApiKey?: string | null,
): Promise<GetVSCodeUrlResponse> {
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,
+20
View File
@@ -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,
});
};
+72 -18
View File
@@ -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<VSCodeUrlResult>({
// 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<VSCodeUrlResult>({
// 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,
};
};