mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 14:17:58 +08:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 ?? [];
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user