refactor: use typescript client for cloud transport (#1621)

* Move cloud transport to typescript client

* docs: add issue 1648 live evidence

* test: align automation cloud import with proxy client

Co-authored-by: OpenHands <openhands@all-hands.dev>

* fix: handle shared-client HttpError shapes at cloud call sites

* chore: Remove PR-only artifacts

---------

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
Co-authored-by: allhands-bot <allhands-bot@users.noreply.github.com>
This commit is contained in:
Graham Neubig
2026-07-16 21:15:17 +07:00
committed by GitHub
co-authored by OpenHands hieptl allhands-bot
parent e06c8ed86b
commit 2fa0296d2f
35 changed files with 949 additions and 904 deletions
@@ -4,7 +4,6 @@ import {
ProfilesClient,
SettingsClient,
} from "@openhands/typescript-client/clients";
import axios from "axios";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import {
__resetActiveStoreForTests,
@@ -13,8 +12,11 @@ import {
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
vi.mock("axios");
import {
getFetchCall,
getJsonBody,
mockJsonResponse,
} from "./cloud/fetch-test-utils";
const {
mockHttpGet,
@@ -44,6 +46,9 @@ const {
mockActivateProfile: vi.fn(),
}));
const originalFetch = global.fetch;
const fetchMock = vi.fn();
vi.mock("@openhands/typescript-client/clients", async () => {
const actual = await vi.importActual<
typeof import("@openhands/typescript-client/clients")
@@ -102,6 +107,8 @@ describe("AgentServerConversationService", () => {
mockActivateProfile.mockReset();
mockSwitchProfile.mockReset();
mockSwitchLLM.mockReset();
fetchMock.mockReset();
global.fetch = originalFetch;
vi.mocked(ConversationClient).mockClear();
vi.mocked(FileClient).mockClear();
vi.mocked(ProfilesClient).mockClear();
@@ -419,6 +426,8 @@ describe("AgentServerConversationService", () => {
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
fetchMock.mockReset();
global.fetch = originalFetch;
});
it("hits the local /api/file/download-trajectory endpoint with responseType blob when active backend is local", async () => {
@@ -886,17 +895,20 @@ describe("AgentServerConversationService", () => {
};
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
vi.mocked(axios.request).mockReset();
vi.mocked(axios.request).mockResolvedValue({ data: { success: true } });
fetchMock.mockResolvedValueOnce(mockJsonResponse({ success: true }));
global.fetch = fetchMock as typeof fetch;
await AgentServerConversationService.switchProfile("conv-1", "haiku");
const [cfg] = vi.mocked(axios.request).mock.calls[0]!;
expect(cfg).toMatchObject({
const [url, init] = getFetchCall(fetchMock);
expect(url).toBe(
"https://app.all-hands.dev/api/v1/app-conversations/conv-1/switch_profile",
);
expect(init).toMatchObject({
method: "POST",
url: "https://app.all-hands.dev/api/v1/app-conversations/conv-1/switch_profile",
data: { profile_name: "haiku" },
headers: { Authorization: "Bearer bearer-token" },
});
expect(getJsonBody(init)).toEqual({ profile_name: "haiku" });
// Cloud resolves the swap server-side: no client-side encrypted profile
// fetch and no direct switch_llm call.
expect(mockGetProfile).not.toHaveBeenCalled();
@@ -918,18 +930,21 @@ describe("AgentServerConversationService", () => {
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
vi.mocked(axios.request).mockReset();
fetchMock.mockReset();
global.fetch = fetchMock as typeof fetch;
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
fetchMock.mockReset();
global.fetch = originalFetch;
});
it("forwards parent_conversation_id, agent_type, and sandbox_id to the cloud createConversation payload", async () => {
// Arrange
vi.mocked(axios.request).mockResolvedValue({
data: {
fetchMock.mockResolvedValueOnce(
mockJsonResponse({
id: "task-1",
status: "WORKING",
app_conversation_id: null,
@@ -937,8 +952,8 @@ describe("AgentServerConversationService", () => {
request: {},
created_at: "2024-01-01",
updated_at: "2024-01-01",
},
});
}),
);
// Act
await AgentServerConversationService.createConversation(
@@ -954,13 +969,13 @@ describe("AgentServerConversationService", () => {
);
// Assert
const [config] = vi.mocked(axios.request).mock.calls[0]!;
expect(config).toMatchObject({
url: `${cloudBackend.host}/api/v1/app-conversations`,
const [url, init] = getFetchCall(fetchMock);
expect(url).toBe(`${cloudBackend.host}/api/v1/app-conversations`);
expect(init).toMatchObject({
method: "POST",
headers: { Authorization: "Bearer bearer-token" },
});
expect((config as { data: Record<string, unknown> }).data).toMatchObject({
expect(getJsonBody(init)).toMatchObject({
parent_conversation_id: "parent-conv-1",
agent_type: "plan",
sandbox_id: "sandbox-9",
@@ -969,7 +984,12 @@ describe("AgentServerConversationService", () => {
it("routes readConversationFile to the cloud file endpoint with the file_path query param", async () => {
// Arrange
vi.mocked(axios.request).mockResolvedValue({ data: "# PLAN content" });
fetchMock.mockResolvedValueOnce(
new Response("# PLAN content", {
status: 200,
headers: { "content-type": "text/plain" },
}),
);
// Act
const content =
@@ -979,12 +999,12 @@ describe("AgentServerConversationService", () => {
// Assert
expect(content).toBe("# PLAN content");
const [config] = vi.mocked(axios.request).mock.calls[0]!;
expect(config).toMatchObject({
const [url, init] = getFetchCall(fetchMock);
expect(init).toMatchObject({
method: "GET",
headers: { Authorization: "Bearer bearer-token" },
});
expect((config as { url: string }).url).toBe(
expect(url).toBe(
`${cloudBackend.host}/api/v1/app-conversations/conv-cloud-1/file?file_path=%2Fworkspace%2Fproject%2F.agents_tmp%2FPLAN.md`,
);
});
+30 -21
View File
@@ -1,4 +1,3 @@
import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
@@ -7,8 +6,11 @@ import {
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
vi.mock("axios");
import {
getFetchCall,
getJsonBody,
mockJsonResponse,
} from "./fetch-test-utils";
const cloudBackend: Backend = {
id: "prod",
@@ -18,23 +20,29 @@ const cloudBackend: Backend = {
kind: "cloud",
};
const originalFetch = global.fetch;
const fetchMock = vi.fn();
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
vi.mocked(axios.request).mockReset();
fetchMock.mockReset();
global.fetch = fetchMock as typeof fetch;
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
fetchMock.mockReset();
global.fetch = originalFetch;
});
describe("AgentServerConversationService cloud branch", () => {
it("createConversation POSTs the cloud payload directly and returns a WORKING task", async () => {
vi.mocked(axios.request).mockResolvedValue({
data: {
fetchMock.mockResolvedValue(
mockJsonResponse({
id: "task-123",
created_by_user_id: null,
status: "WORKING",
@@ -44,8 +52,8 @@ describe("AgentServerConversationService cloud branch", () => {
request: {},
created_at: "2026-05-06T00:00:00Z",
updated_at: "2026-05-06T00:00:00Z",
},
});
}),
);
const result = await AgentServerConversationService.createConversation(
"fix the bug",
@@ -59,15 +67,14 @@ describe("AgentServerConversationService cloud branch", () => {
},
);
expect(axios.request).toHaveBeenCalledOnce();
const [config] = vi.mocked(axios.request).mock.calls[0]!;
expect(config).toMatchObject({
url: `${cloudBackend.host}/api/v1/app-conversations`,
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = getFetchCall(fetchMock);
expect(url).toBe(`${cloudBackend.host}/api/v1/app-conversations`);
expect(init).toMatchObject({
method: "POST",
headers: { Authorization: "Bearer bearer-token" },
});
const requestBody = (config as { data: Record<string, unknown> }).data;
const requestBody = getJsonBody(init);
// cloud payload shape — flat fields, NO encrypted-settings round-trip.
expect(requestBody.selected_repository).toBe("user/repo");
@@ -89,8 +96,8 @@ describe("AgentServerConversationService cloud branch", () => {
});
it("getStartTask polls /api/v1/app-conversations/start-tasks?ids= directly", async () => {
vi.mocked(axios.request).mockResolvedValue({
data: [
fetchMock.mockResolvedValue(
mockJsonResponse([
{
id: "task-123",
created_by_user_id: null,
@@ -102,15 +109,17 @@ describe("AgentServerConversationService cloud branch", () => {
created_at: "2026-05-06T00:00:00Z",
updated_at: "2026-05-06T00:00:00Z",
},
],
});
]),
);
const result =
await AgentServerConversationService.getStartTask("task-123");
const [config] = vi.mocked(axios.request).mock.calls[0]!;
expect(config).toMatchObject({
url: `${cloudBackend.host}/api/v1/app-conversations/start-tasks?ids=task-123`,
const [url, init] = getFetchCall(fetchMock);
expect(url).toBe(
`${cloudBackend.host}/api/v1/app-conversations/start-tasks?ids=task-123`,
);
expect(init).toMatchObject({
method: "GET",
headers: { Authorization: "Bearer bearer-token" },
});
+13 -11
View File
@@ -1,4 +1,3 @@
import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
@@ -7,8 +6,7 @@ import {
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
vi.mock("axios");
import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
const cloudBackend: Backend = {
id: "prod",
@@ -18,30 +16,34 @@ const cloudBackend: Backend = {
kind: "cloud",
};
const originalFetch = global.fetch;
const fetchMock = vi.fn();
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
vi.mocked(axios.request).mockReset();
fetchMock.mockReset();
fetchMock.mockResolvedValue(mockJsonResponse({ success: true }));
global.fetch = fetchMock as typeof fetch;
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
fetchMock.mockReset();
global.fetch = originalFetch;
});
describe("AgentServerConversationService.deleteConversation cloud branch", () => {
it("calls the cloud DELETE app-conversations endpoint directly", async () => {
vi.mocked(axios.request).mockResolvedValue({ data: { success: true } });
await AgentServerConversationService.deleteConversation("conv-abc");
expect(axios.request).toHaveBeenCalledOnce();
const [config] = vi.mocked(axios.request).mock.calls[0]!;
expect(config).toMatchObject({
url: `${cloudBackend.host}/api/v1/app-conversations/conv-abc`,
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = getFetchCall(fetchMock);
expect(url).toBe(`${cloudBackend.host}/api/v1/app-conversations/conv-abc`);
expect(init).toMatchObject({
method: "DELETE",
headers: { Authorization: "Bearer bearer-token" },
});
@@ -1,4 +1,3 @@
import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
@@ -7,8 +6,11 @@ import {
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
vi.mock("axios");
import {
getFetchCall,
mockBlobResponse,
mockJsonResponse,
} from "./fetch-test-utils";
const cloudBackend: Backend = {
id: "prod",
@@ -18,36 +20,44 @@ const cloudBackend: Backend = {
kind: "cloud",
};
const originalFetch = global.fetch;
const fetchMock = vi.fn();
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
vi.mocked(axios.request).mockReset();
fetchMock.mockReset();
fetchMock.mockResolvedValue(mockJsonResponse({}));
global.fetch = fetchMock as typeof fetch;
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
fetchMock.mockReset();
global.fetch = originalFetch;
});
describe("AgentServerConversationService.downloadConversation cloud branch", () => {
it("calls the cloud download endpoint directly with responseType blob and returns the Blob", async () => {
const zipBlob = new Blob(["zip-bytes"], { type: "application/zip" });
vi.mocked(axios.request).mockResolvedValue({ data: zipBlob });
fetchMock.mockResolvedValueOnce(
mockBlobResponse("zip-bytes", "application/zip"),
);
const result =
await AgentServerConversationService.downloadConversation("conv-abc");
expect(axios.request).toHaveBeenCalledOnce();
const [config] = vi.mocked(axios.request).mock.calls[0]!;
expect(config).toMatchObject({
url: `${cloudBackend.host}/api/v1/app-conversations/conv-abc/download`,
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = getFetchCall(fetchMock);
expect(url).toBe(
`${cloudBackend.host}/api/v1/app-conversations/conv-abc/download`,
);
expect(init).toMatchObject({
method: "GET",
headers: { Authorization: "Bearer bearer-token" },
responseType: "blob",
});
expect(result).toBe(zipBlob);
await expect(result.text()).resolves.toBe("zip-bytes");
});
});
+14 -10
View File
@@ -1,4 +1,3 @@
import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
@@ -10,8 +9,7 @@ import AgentServerConversationService from "#/api/conversation-service/agent-ser
import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types";
import { pauseConversation } from "#/hooks/mutation/conversation-mutation-utils";
import { ExecutionStatus } from "#/types/agent-server/core/base/common";
vi.mock("axios");
import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
const cloudBackend: Backend = {
id: "prod",
@@ -44,17 +42,24 @@ const buildConversation = (
...overrides,
});
const originalFetch = global.fetch;
const fetchMock = vi.fn();
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
vi.mocked(axios.request).mockReset();
fetchMock.mockReset();
fetchMock.mockResolvedValue(mockJsonResponse({ success: true }));
global.fetch = fetchMock as typeof fetch;
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
fetchMock.mockReset();
global.fetch = originalFetch;
vi.restoreAllMocks();
});
@@ -64,14 +69,13 @@ describe("pauseConversation cloud branch", () => {
AgentServerConversationService,
"batchGetAppConversations",
).mockResolvedValue([buildConversation({ sandbox_id: "sandbox-xyz" })]);
vi.mocked(axios.request).mockResolvedValue({ data: { success: true } });
await pauseConversation("conv-abc");
expect(axios.request).toHaveBeenCalledOnce();
const [config] = vi.mocked(axios.request).mock.calls[0]!;
expect(config).toMatchObject({
url: `${cloudBackend.host}/api/v1/sandboxes/sandbox-xyz/pause`,
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = getFetchCall(fetchMock);
expect(url).toBe(`${cloudBackend.host}/api/v1/sandboxes/sandbox-xyz/pause`);
expect(init).toMatchObject({
method: "POST",
headers: { Authorization: "Bearer bearer-token" },
});
@@ -84,6 +88,6 @@ describe("pauseConversation cloud branch", () => {
).mockResolvedValue([buildConversation({ sandbox_id: null })]);
await expect(pauseConversation("conv-abc")).rejects.toThrow(/sandbox_id/);
expect(axios.request).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,3 @@
import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
@@ -7,8 +6,11 @@ import {
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
vi.mock("axios");
import {
getFetchCall,
getJsonBody,
mockJsonResponse,
} from "./fetch-test-utils";
const cloudBackend: Backend = {
id: "prod",
@@ -18,38 +20,44 @@ const cloudBackend: Backend = {
kind: "cloud",
};
const originalFetch = global.fetch;
const fetchMock = vi.fn();
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
vi.mocked(axios.request).mockReset();
fetchMock.mockReset();
fetchMock.mockResolvedValue(
mockJsonResponse({ id: "conv-abc", public: true }),
);
global.fetch = fetchMock as typeof fetch;
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
fetchMock.mockReset();
global.fetch = originalFetch;
});
describe("AgentServerConversationService.updateConversationPublicFlag", () => {
it("PATCHes /api/v1/app-conversations/{id} directly on a cloud backend", async () => {
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
vi.mocked(axios.request).mockResolvedValue({
data: { id: "conv-abc", public: true },
});
await AgentServerConversationService.updateConversationPublicFlag(
"conv-abc",
true,
);
expect(axios.request).toHaveBeenCalledOnce();
const [config] = vi.mocked(axios.request).mock.calls[0]!;
expect(config).toMatchObject({
url: `${cloudBackend.host}/api/v1/app-conversations/conv-abc`,
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = getFetchCall(fetchMock);
expect(url).toBe(`${cloudBackend.host}/api/v1/app-conversations/conv-abc`);
expect(init).toMatchObject({
method: "PATCH",
headers: { Authorization: "Bearer bearer-token" },
data: { public: true },
});
expect(getJsonBody(init)).toEqual({ public: true });
});
it("rejects without calling the cloud API when the active backend is local", async () => {
@@ -60,6 +68,6 @@ describe("AgentServerConversationService.updateConversationPublicFlag", () => {
true,
),
).rejects.toThrow(/cloud backend/);
expect(axios.request).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,3 @@
import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
@@ -7,8 +6,11 @@ import {
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
vi.mock("axios");
import {
getFetchCall,
getJsonBody,
mockJsonResponse,
} from "./fetch-test-utils";
const cloudBackend: Backend = {
id: "prod",
@@ -48,9 +50,12 @@ const runtimeResponse = {
},
};
const fetchMock = vi.fn();
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
fetchMock.mockReset();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
@@ -61,12 +66,13 @@ describe("AgentServerConversationService.getRuntimeConversation", () => {
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
vi.mocked(axios.post).mockReset();
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
it("routes through /api/cloud-proxy targeting the conversation runtime host", async () => {
// Arrange
vi.mocked(axios.post).mockResolvedValue({ data: runtimeResponse });
fetchMock.mockResolvedValue(mockJsonResponse(runtimeResponse));
const conversationUrl =
"http://abc123.runtime.all-hands.dev/api/conversations/conv-abc";
@@ -79,8 +85,9 @@ describe("AgentServerConversationService.getRuntimeConversation", () => {
);
// Assert
expect(axios.post).toHaveBeenCalledOnce();
const [url, body] = vi.mocked(axios.post).mock.calls[0]!;
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = getFetchCall(fetchMock);
const body = getJsonBody(init);
expect(url).toMatch(/\/api\/cloud-proxy$/);
expect(body).toMatchObject({
host: "http://abc123.runtime.all-hands.dev",
@@ -97,20 +104,13 @@ describe("AgentServerConversationService.getRuntimeConversation", () => {
__resetActiveStoreForTests();
setRegisteredBackends([localBackend]);
setActiveSelection({ backendId: localBackend.id });
vi.mocked(axios.post).mockReset();
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
it("targets the conversation_url host (not the active backend host) and forwards X-Session-API-Key", async () => {
// Arrange
const fetchMock = vi.fn(() =>
Promise.resolve(
new Response(JSON.stringify(runtimeResponse), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
);
vi.stubGlobal("fetch", fetchMock);
fetchMock.mockResolvedValue(mockJsonResponse(runtimeResponse));
const conversationUrl =
"http://192.168.1.42:8888/api/conversations/conv-abc";
@@ -123,15 +123,11 @@ describe("AgentServerConversationService.getRuntimeConversation", () => {
);
// Assert
expect(axios.post).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledOnce();
const call = fetchMock.mock.calls[0] as unknown as [
RequestInfo,
RequestInit,
];
expect(String(call[0])).toContain("192.168.1.42:8888");
expect(String(call[0])).not.toContain(localBackend.host);
expect(call[1]?.headers).toMatchObject({
const [url, init] = getFetchCall(fetchMock);
expect(url).toContain("192.168.1.42:8888");
expect(url).not.toContain(localBackend.host);
expect(init.headers).toMatchObject({
"X-Session-API-Key": "session-xyz",
});
expect(result.id).toBe("conv-abc");
+33
View File
@@ -0,0 +1,33 @@
import { vi } from "vitest";
type FetchMock = ReturnType<typeof vi.fn>;
export function mockJsonResponse(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
statusText: status === 200 ? "OK" : "Error",
headers: { "content-type": "application/json" },
});
}
export function mockBlobResponse(
body: BodyInit,
contentType: string,
): Response {
return new Response(body, {
status: 200,
headers: { "content-type": contentType },
});
}
export function getFetchCall(
fetchMock: FetchMock,
index = 0,
): [string, RequestInit] {
const [url, init] = fetchMock.mock.calls[index] as [string, RequestInit];
return [url, init ?? {}];
}
export function getJsonBody(init: RequestInit): Record<string, unknown> {
return JSON.parse(String(init.body ?? "{}")) as Record<string, unknown>;
}
+15 -17
View File
@@ -1,4 +1,3 @@
import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
@@ -7,8 +6,7 @@ import {
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import { getCloudRepositoryBranches } from "#/api/cloud/git-service.api";
vi.mock("axios");
import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
const cloudBackend: Backend = {
id: "prod",
@@ -18,28 +16,29 @@ const cloudBackend: Backend = {
kind: "cloud",
};
const emptyBranchPage = {
data: { items: [], next_page_id: null },
};
const emptyBranchPage = { items: [], next_page_id: null };
const originalFetch = global.fetch;
const fetchMock = vi.fn();
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
vi.mocked(axios.request).mockReset();
fetchMock.mockReset();
fetchMock.mockResolvedValue(mockJsonResponse(emptyBranchPage));
global.fetch = fetchMock as typeof fetch;
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
fetchMock.mockReset();
global.fetch = originalFetch;
});
describe("getCloudRepositoryBranches", () => {
it("includes an empty query parameter when listing all branches so the upstream schema is satisfied", async () => {
// Arrange
vi.mocked(axios.request).mockResolvedValueOnce(emptyBranchPage);
// Act
await getCloudRepositoryBranches({
provider: "github",
@@ -47,15 +46,15 @@ describe("getCloudRepositoryBranches", () => {
});
// Assert
const [config] = vi.mocked(axios.request).mock.calls[0]!;
const url = (config as { url: string }).url;
const [url, init] = getFetchCall(fetchMock);
expect(init).toMatchObject({
method: "GET",
headers: { Authorization: "Bearer bearer-token" },
});
expect(url).toMatch(/[?&]query=(&|$)/);
});
it("forwards a non-empty query parameter when searching branches", async () => {
// Arrange
vi.mocked(axios.request).mockResolvedValueOnce(emptyBranchPage);
// Act
await getCloudRepositoryBranches({
provider: "github",
@@ -64,8 +63,7 @@ describe("getCloudRepositoryBranches", () => {
});
// Assert
const [config] = vi.mocked(axios.request).mock.calls[0]!;
const url = (config as { url: string }).url;
const [url] = getFetchCall(fetchMock);
expect(url).toContain("query=feature%2Flogin");
});
});
+19 -15
View File
@@ -1,4 +1,3 @@
import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
@@ -7,8 +6,7 @@ import {
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import { getCloudOrganizationMe } from "#/api/cloud/organization-service.api";
vi.mock("axios");
import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
const cloudBackend: Backend = {
id: "prod",
@@ -18,37 +16,43 @@ const cloudBackend: Backend = {
kind: "cloud",
};
const originalFetch = global.fetch;
const fetchMock = vi.fn();
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
vi.mocked(axios.request).mockReset();
fetchMock.mockReset();
global.fetch = fetchMock as typeof fetch;
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
fetchMock.mockReset();
global.fetch = originalFetch;
});
describe("cloud organization /me", () => {
it("calls /api/organizations/{orgId}/me directly and returns user_id", async () => {
const orgId = "0b93b5f2-5396-49f2-8d98-61f906184270";
vi.mocked(axios.request).mockResolvedValue({
data: {
fetchMock.mockResolvedValue(
mockJsonResponse({
org_id: orgId,
user_id: orgId,
email: "hieptl.developer@gmail.com",
role: "owner",
permissions: ["view_org_settings", "edit_org_settings"],
},
});
}),
);
const result = await getCloudOrganizationMe(orgId);
const [config] = vi.mocked(axios.request).mock.calls[0]!;
expect(config).toMatchObject({
url: `${cloudBackend.host}/api/organizations/${orgId}/me`,
const [url, init] = getFetchCall(fetchMock);
expect(url).toBe(`${cloudBackend.host}/api/organizations/${orgId}/me`);
expect(init).toMatchObject({
method: "GET",
headers: { Authorization: "Bearer bearer-token" },
});
@@ -62,14 +66,14 @@ describe("cloud organization /me", () => {
it("returns null permissions when the app-server omits them (older version)", async () => {
const orgId = "0b93b5f2-5396-49f2-8d98-61f906184270";
vi.mocked(axios.request).mockResolvedValue({
data: {
fetchMock.mockResolvedValue(
mockJsonResponse({
org_id: orgId,
user_id: orgId,
email: "x@example.com",
role: "member",
},
});
}),
);
const result = await getCloudOrganizationMe(orgId);
@@ -1,5 +1,5 @@
import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HttpError } from "@openhands/typescript-client";
import {
__resetActiveStoreForTests,
setRegisteredBackends,
@@ -10,8 +10,6 @@ import {
} from "#/api/cloud/organization-service.api";
import type { Backend } from "#/api/backend-registry/types";
vi.mock("axios");
const cloudBackend: Backend = {
id: "prod",
name: "Production",
@@ -20,35 +18,48 @@ const cloudBackend: Backend = {
kind: "cloud",
};
const originalFetch = global.fetch;
const fetchMock = vi.fn();
function mockJsonResponse(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
statusText: status === 200 ? "OK" : "Error",
headers: { "content-type": "application/json" },
});
}
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
setRegisteredBackends([]);
vi.mocked(axios.request).mockReset();
fetchMock.mockReset();
global.fetch = fetchMock as typeof fetch;
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
vi.mocked(axios.request).mockReset();
fetchMock.mockReset();
global.fetch = originalFetch;
});
describe("cloud organization-service", () => {
it("getCloudOrganizations calls the cloud API directly and returns normalized data", async () => {
vi.mocked(axios.request).mockResolvedValue({
data: {
fetchMock.mockResolvedValueOnce(
mockJsonResponse({
items: [{ id: "org-1", name: "Personal" }],
current_org_id: "org-1",
},
});
}),
);
const result = await getCloudOrganizations(cloudBackend);
expect(axios.request).toHaveBeenCalledOnce();
const [config] = vi.mocked(axios.request).mock.calls[0]!;
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0]!;
expect(config).toMatchObject({
url: `${cloudBackend.host}/api/organizations`,
expect(url).toBe(`${cloudBackend.host}/api/organizations`);
expect(init).toMatchObject({
method: "GET",
headers: { Authorization: "Bearer bearer-token" },
});
@@ -60,21 +71,21 @@ describe("cloud organization-service", () => {
});
it("getCurrentCloudApiKey hits /api/keys/current and returns the bound orgId", async () => {
vi.mocked(axios.request).mockResolvedValue({
data: {
fetchMock.mockResolvedValueOnce(
mockJsonResponse({
id: "key-1",
name: "k",
org_id: "org-bound",
user_id: "user-1",
auth_type: "bearer",
},
});
}),
);
const result = await getCurrentCloudApiKey(cloudBackend);
const [config] = vi.mocked(axios.request).mock.calls[0]!;
expect(config).toMatchObject({
url: `${cloudBackend.host}/api/keys/current`,
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe(`${cloudBackend.host}/api/keys/current`);
expect(init).toMatchObject({
method: "GET",
headers: { Authorization: "Bearer bearer-token" },
});
@@ -82,11 +93,7 @@ describe("cloud organization-service", () => {
});
it("getCurrentCloudApiKey treats an upstream 400 as a legacy key (no binding)", async () => {
const error = Object.assign(new Error("Bad Request"), {
response: { status: 400 },
});
vi.mocked(axios.isAxiosError).mockReturnValueOnce(true);
vi.mocked(axios.request).mockRejectedValueOnce(error);
fetchMock.mockResolvedValueOnce(mockJsonResponse({ detail: "bad" }, 400));
const result = await getCurrentCloudApiKey(cloudBackend);
@@ -94,12 +101,12 @@ describe("cloud organization-service", () => {
});
it("getCurrentCloudApiKey rethrows non-400 upstream errors (e.g. revoked key)", async () => {
const error = Object.assign(new Error("Unauthorized"), {
response: { status: 401 },
});
vi.mocked(axios.isAxiosError).mockReturnValueOnce(true);
vi.mocked(axios.request).mockRejectedValueOnce(error);
fetchMock.mockResolvedValueOnce(
mockJsonResponse({ detail: "unauthorized" }, 401),
);
await expect(getCurrentCloudApiKey(cloudBackend)).rejects.toBe(error);
await expect(getCurrentCloudApiKey(cloudBackend)).rejects.toBeInstanceOf(
HttpError,
);
});
});
+76 -59
View File
@@ -1,4 +1,3 @@
import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
@@ -9,8 +8,11 @@ import type { Backend } from "#/api/backend-registry/types";
import ProfilesService, {
type SaveProfileRequest,
} from "#/api/profiles-service/profiles-service.api";
vi.mock("axios");
import {
getFetchCall,
getJsonBody,
mockJsonResponse,
} from "./fetch-test-utils";
const cloudBackend: Backend = {
id: "prod",
@@ -23,17 +25,23 @@ const cloudBackend: Backend = {
const ORG_ID = "org-1";
const ORG_BASE = `https://app.all-hands.dev/api/organizations/${ORG_ID}/profiles`;
const SETTINGS_BASE = "https://app.all-hands.dev/api/v1/settings/profiles";
const originalFetch = global.fetch;
const fetchMock = vi.fn();
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
vi.mocked(axios.request).mockReset();
fetchMock.mockReset();
fetchMock.mockResolvedValue(mockJsonResponse({}));
global.fetch = fetchMock as typeof fetch;
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
fetchMock.mockReset();
global.fetch = originalFetch;
});
// With an org bound, profile CRUD goes through the org-gated routes so the
@@ -44,37 +52,40 @@ describe("ProfilesService against a cloud org (gated org routes)", () => {
});
it("lists profiles via GET /api/organizations/{orgId}/profiles", async () => {
vi.mocked(axios.request).mockResolvedValueOnce({
data: {
fetchMock.mockResolvedValueOnce(
mockJsonResponse({
profiles: [
{ name: "gpt", model: "gpt-4o", base_url: null, api_key_set: true },
],
active_profile: "gpt",
},
});
}),
);
const res = await ProfilesService.listProfiles();
const [cfg] = vi.mocked(axios.request).mock.calls[0]!;
expect(cfg).toMatchObject({
const [url, init] = getFetchCall(fetchMock);
expect(url).toBe(ORG_BASE);
expect(init).toMatchObject({
method: "GET",
url: ORG_BASE,
headers: { Authorization: "Bearer bearer-token" },
});
expect(res.active_profile).toBe("gpt");
});
it("fetches a profile and maps the org `llm` onto `config`", async () => {
vi.mocked(axios.request).mockResolvedValueOnce({
data: { name: "my profile", llm: { model: "gpt-4o", api_key: null } },
});
fetchMock.mockResolvedValueOnce(
mockJsonResponse({
name: "my profile",
llm: { model: "gpt-4o", api_key: null },
}),
);
const res = await ProfilesService.getProfile("my profile");
const [cfg] = vi.mocked(axios.request).mock.calls[0]!;
expect(cfg).toMatchObject({
const [url, init] = getFetchCall(fetchMock);
expect(url).toBe(`${ORG_BASE}/my%20profile`);
expect(init).toMatchObject({
method: "GET",
url: `${ORG_BASE}/my%20profile`,
});
expect(res).toEqual({
name: "my profile",
@@ -84,64 +95,68 @@ describe("ProfilesService against a cloud org (gated org routes)", () => {
});
it("saves a profile via POST .../{name} forwarding the request body", async () => {
vi.mocked(axios.request).mockResolvedValueOnce({
data: { name: "gpt", message: "Profile 'gpt' saved" },
});
fetchMock.mockResolvedValueOnce(
mockJsonResponse({ name: "gpt", message: "Profile 'gpt' saved" }),
);
await ProfilesService.saveProfile("gpt", {
llm: { model: "gpt-4o" } as SaveProfileRequest["llm"],
include_secrets: true,
});
const [cfg] = vi.mocked(axios.request).mock.calls[0]!;
expect(cfg).toMatchObject({
const [url, init] = getFetchCall(fetchMock);
expect(url).toBe(`${ORG_BASE}/gpt`);
expect(init).toMatchObject({
method: "POST",
url: `${ORG_BASE}/gpt`,
data: { llm: { model: "gpt-4o" }, include_secrets: true },
});
expect(getJsonBody(init)).toEqual({
llm: { model: "gpt-4o" },
include_secrets: true,
});
});
it("deletes a profile via DELETE .../{name}", async () => {
vi.mocked(axios.request).mockResolvedValueOnce({
data: { name: "gpt", message: "Profile 'gpt' deleted" },
});
fetchMock.mockResolvedValueOnce(
mockJsonResponse({ name: "gpt", message: "Profile 'gpt' deleted" }),
);
await ProfilesService.deleteProfile("gpt");
const [cfg] = vi.mocked(axios.request).mock.calls[0]!;
expect(cfg).toMatchObject({ method: "DELETE", url: `${ORG_BASE}/gpt` });
const [url, init] = getFetchCall(fetchMock);
expect(url).toBe(`${ORG_BASE}/gpt`);
expect(init).toMatchObject({ method: "DELETE" });
});
it("renames a profile via POST .../{name}/rename with new_name", async () => {
vi.mocked(axios.request).mockResolvedValueOnce({
data: { name: "new", message: "renamed" },
});
fetchMock.mockResolvedValueOnce(
mockJsonResponse({ name: "new", message: "renamed" }),
);
await ProfilesService.renameProfile("old", "new");
const [cfg] = vi.mocked(axios.request).mock.calls[0]!;
expect(cfg).toMatchObject({
const [url, init] = getFetchCall(fetchMock);
expect(url).toBe(`${ORG_BASE}/old/rename`);
expect(init).toMatchObject({
method: "POST",
url: `${ORG_BASE}/old/rename`,
data: { new_name: "new" },
});
expect(getJsonBody(init)).toEqual({ new_name: "new" });
});
it("activates a profile and maps the org `llm` onto `llm_applied`", async () => {
vi.mocked(axios.request).mockResolvedValueOnce({
data: {
fetchMock.mockResolvedValueOnce(
mockJsonResponse({
name: "gpt",
message: "Switched to profile 'gpt'",
llm: { model: "gpt-4o" },
},
});
}),
);
const res = await ProfilesService.activateProfile("gpt");
const [cfg] = vi.mocked(axios.request).mock.calls[0]!;
expect(cfg).toMatchObject({
const [url, init] = getFetchCall(fetchMock);
expect(url).toBe(`${ORG_BASE}/gpt/activate`);
expect(init).toMatchObject({
method: "POST",
url: `${ORG_BASE}/gpt/activate`,
});
expect(res).toEqual({
name: "gpt",
@@ -159,41 +174,43 @@ describe("ProfilesService on a cloud backend with no org (fallback)", () => {
});
it("lists via the per-user settings route", async () => {
vi.mocked(axios.request).mockResolvedValueOnce({
data: { profiles: [], active_profile: null },
});
fetchMock.mockResolvedValueOnce(
mockJsonResponse({ profiles: [], active_profile: null }),
);
await ProfilesService.listProfiles();
const [cfg] = vi.mocked(axios.request).mock.calls[0]!;
expect(cfg).toMatchObject({ method: "GET", url: SETTINGS_BASE });
const [url, init] = getFetchCall(fetchMock);
expect(url).toBe(SETTINGS_BASE);
expect(init).toMatchObject({ method: "GET" });
});
it("saves via the per-user settings route", async () => {
vi.mocked(axios.request).mockResolvedValueOnce({
data: { name: "gpt", message: "saved" },
});
fetchMock.mockResolvedValueOnce(
mockJsonResponse({ name: "gpt", message: "saved" }),
);
await ProfilesService.saveProfile("gpt", {
llm: { model: "gpt-4o" } as SaveProfileRequest["llm"],
include_secrets: true,
});
const [cfg] = vi.mocked(axios.request).mock.calls[0]!;
expect(cfg).toMatchObject({ method: "POST", url: `${SETTINGS_BASE}/gpt` });
const [url, init] = getFetchCall(fetchMock);
expect(url).toBe(`${SETTINGS_BASE}/gpt`);
expect(init).toMatchObject({ method: "POST" });
});
it("activates via the per-user settings route and maps `model`", async () => {
vi.mocked(axios.request).mockResolvedValueOnce({
data: { name: "gpt", message: "ok", model: "gpt-4o" },
});
fetchMock.mockResolvedValueOnce(
mockJsonResponse({ name: "gpt", message: "ok", model: "gpt-4o" }),
);
const res = await ProfilesService.activateProfile("gpt");
const [cfg] = vi.mocked(axios.request).mock.calls[0]!;
expect(cfg).toMatchObject({
const [url, init] = getFetchCall(fetchMock);
expect(url).toBe(`${SETTINGS_BASE}/gpt/activate`);
expect(init).toMatchObject({
method: "POST",
url: `${SETTINGS_BASE}/gpt/activate`,
});
expect(res.llm_applied).toBe(true);
});
+36 -30
View File
@@ -1,4 +1,3 @@
import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
@@ -8,8 +7,6 @@ import {
import { callCloudProxy } from "#/api/cloud/proxy";
import type { Backend } from "#/api/backend-registry/types";
vi.mock("axios");
const cloudPersonal: Backend = {
id: "cloud-personal",
name: "Production - Personal",
@@ -26,20 +23,29 @@ const cloudAcme: Backend = {
kind: "cloud",
};
const originalFetch = global.fetch;
const fetchMock = vi.fn();
function mockJsonResponse(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json" },
});
}
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
vi.mocked(axios.request).mockReset();
vi.mocked(axios.request).mockResolvedValue({ data: {} });
vi.mocked(axios.post).mockReset();
vi.mocked(axios.post).mockResolvedValue({ data: {} });
fetchMock.mockReset();
fetchMock.mockResolvedValue(mockJsonResponse({}));
global.fetch = fetchMock as typeof fetch;
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
vi.mocked(axios.request).mockReset();
vi.mocked(axios.post).mockReset();
fetchMock.mockReset();
global.fetch = originalFetch;
});
describe("callCloudProxy X-Org-Id injection", () => {
@@ -63,13 +69,13 @@ describe("callCloudProxy X-Org-Id injection", () => {
// Assert — the request carries the X-Org-Id of the active selection so the
// cloud backend can scope this request to the user's locally-chosen org
// without depending on user.current_org_id.
const [config] = vi.mocked(axios.request).mock.calls[0]!;
expect(config).toMatchObject({
url: `${cloudPersonal.host}/api/v1/app-conversations/search`,
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe(`${cloudPersonal.host}/api/v1/app-conversations/search`);
expect(init).toMatchObject({
method: "GET",
});
expect(
(config as { headers: Record<string, string> }).headers["X-Org-Id"],
(init as { headers: Record<string, string> }).headers["X-Org-Id"],
).toBe("org-personal-uuid");
});
@@ -92,9 +98,9 @@ describe("callCloudProxy X-Org-Id injection", () => {
});
// Assert
const [config] = vi.mocked(axios.request).mock.calls[0]!;
const [, init] = fetchMock.mock.calls[0]!;
expect(
(config as { headers: Record<string, string> }).headers,
(init as { headers: Record<string, string> }).headers,
).not.toHaveProperty("X-Org-Id");
});
});
@@ -107,7 +113,7 @@ describe("callCloudProxy automation direct routing", () => {
setRegisteredBackends([cloudPersonal]);
setActiveSelection({ backendId: cloudPersonal.id, orgId: null });
const page = { automations: [], total: 0 };
vi.mocked(axios.request).mockResolvedValue({ data: page });
fetchMock.mockResolvedValueOnce(mockJsonResponse(page));
// Act
const result = await callCloudProxy({
@@ -119,14 +125,15 @@ describe("callCloudProxy automation direct routing", () => {
// Assert — the browser calls the automation API on the cloud host
// directly, authenticated by the backend's API key, and no envelope
// POST reaches /api/cloud-proxy.
expect(axios.post).not.toHaveBeenCalled();
const [config] = vi.mocked(axios.request).mock.calls[0]!;
expect(config).toMatchObject({
url: `${cloudPersonal.host}/api/automation/v1?limit=50&offset=0`,
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe(
`${cloudPersonal.host}/api/automation/v1?limit=50&offset=0`,
);
expect(init).toMatchObject({
method: "GET",
});
expect(
(config as { headers: Record<string, string> }).headers.Authorization,
(init as { headers: Record<string, string> }).headers.Authorization,
).toBe(`Bearer ${cloudPersonal.apiKey}`);
expect(result).toEqual(page);
});
@@ -148,11 +155,9 @@ describe("callCloudProxy automation direct routing", () => {
});
// Assert
const [config] = vi.mocked(axios.request).mock.calls[0]!;
expect(config).toMatchObject({
responseType: "blob",
timeout: 5000,
});
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe(`${cloudPersonal.host}/api/automation/v1/auto-1/tarball`);
expect(init).toMatchObject({ method: "GET" });
});
});
@@ -165,7 +170,7 @@ describe("callCloudProxy hostOverride routing", () => {
// local GUI origin.
setRegisteredBackends([cloudPersonal]);
setActiveSelection({ backendId: cloudPersonal.id, orgId: null });
vi.mocked(axios.post).mockResolvedValue({ data: { items: [] } });
fetchMock.mockResolvedValueOnce(mockJsonResponse({ items: [] }));
// Act
const result = await callCloudProxy({
@@ -178,9 +183,9 @@ describe("callCloudProxy hostOverride routing", () => {
// Assert — the browser only makes a same-origin POST to the bundled
// agent-server's proxy endpoint carrying the upstream call as an
// envelope, and the upstream payload is unwrapped for the caller.
expect(axios.request).not.toHaveBeenCalled();
const [url, envelope] = vi.mocked(axios.post).mock.calls[0]!;
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toMatch(/\/api\/cloud-proxy$/);
const envelope = JSON.parse((init as { body: string }).body);
expect(envelope).toMatchObject({
host: runtimeHost,
method: "GET",
@@ -208,7 +213,8 @@ describe("callCloudProxy hostOverride routing", () => {
});
// Assert
const [, envelope] = vi.mocked(axios.post).mock.calls[0]!;
const [, init] = fetchMock.mock.calls[0]!;
const envelope = JSON.parse((init as { body: string }).body);
expect(
(envelope as { headers: Record<string, string> }).headers,
).toMatchObject({
+12 -9
View File
@@ -1,4 +1,3 @@
import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
@@ -7,8 +6,7 @@ import {
} 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");
import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
const cloudBackend: Backend = {
id: "cloud-prod",
@@ -18,19 +16,24 @@ const cloudBackend: Backend = {
kind: "cloud",
};
const originalFetch = global.fetch;
const fetchMock = vi.fn();
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
vi.mocked(axios.request).mockReset();
vi.mocked(axios.request).mockResolvedValue({ data: [] });
fetchMock.mockReset();
fetchMock.mockResolvedValue(mockJsonResponse([]));
global.fetch = fetchMock as typeof fetch;
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
vi.mocked(axios.request).mockReset();
fetchMock.mockReset();
global.fetch = originalFetch;
});
describe("batchGetCloudSandboxes", () => {
@@ -44,12 +47,12 @@ describe("batchGetCloudSandboxes", () => {
// Act
await batchGetCloudSandboxes(ids);
const [config] = vi.mocked(axios.request).mock.calls[0]!;
expect(config).toMatchObject({
const [url, init] = getFetchCall(fetchMock);
expect(init).toMatchObject({
method: "GET",
headers: { Authorization: "Bearer bearer-token" },
});
expect((config as { url: string }).url).toBe(
expect(url).toBe(
`${cloudBackend.host}/api/v1/sandboxes?id=sandbox-a&id=sandbox-b`,
);
});
+69 -41
View File
@@ -1,4 +1,3 @@
import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
@@ -7,8 +6,11 @@ import {
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import { SecretsService } from "#/api/secrets-service";
vi.mock("axios");
import {
getFetchCall,
getJsonBody,
mockJsonResponse,
} from "./fetch-test-utils";
const cloudBackend: Backend = {
id: "prod",
@@ -18,105 +20,131 @@ const cloudBackend: Backend = {
kind: "cloud",
};
const originalFetch = global.fetch;
const fetchMock = vi.fn();
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
vi.mocked(axios.request).mockReset();
fetchMock.mockReset();
fetchMock.mockResolvedValue(mockJsonResponse({}));
global.fetch = fetchMock as typeof fetch;
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
fetchMock.mockReset();
global.fetch = originalFetch;
});
describe("SecretsService against cloud backend", () => {
it("paginates getSecrets directly and returns the merged list", async () => {
vi.mocked(axios.request)
.mockResolvedValueOnce({
data: {
fetchMock
.mockResolvedValueOnce(
mockJsonResponse({
items: [
{ name: "ALPHA", description: "first" },
{ name: "BETA", description: "second" },
],
next_page_id: "BETA",
},
})
.mockResolvedValueOnce({
data: {
}),
)
.mockResolvedValueOnce(
mockJsonResponse({
items: [{ name: "GAMMA", description: "third" }],
next_page_id: null,
},
});
}),
);
const secrets = await SecretsService.getSecrets();
expect(vi.mocked(axios.request)).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
const [firstConfig] = vi.mocked(axios.request).mock.calls[0]!;
expect(firstConfig).toMatchObject({
const [firstUrl, firstInit] = getFetchCall(fetchMock, 0);
expect(firstInit).toMatchObject({
method: "GET",
headers: { Authorization: "Bearer bearer-token" },
});
expect((firstConfig as { url: string }).url).toMatch(
expect(firstUrl).toMatch(
/^https:\/\/app\.all-hands\.dev\/api\/v1\/secrets\/search\?/,
);
expect((firstConfig as { url: string }).url).not.toContain("page_id=");
expect(firstUrl).not.toContain("page_id=");
const [secondConfig] = vi.mocked(axios.request).mock.calls[1]!;
expect((secondConfig as { url: string }).url).toContain("page_id=BETA");
const [secondUrl] = getFetchCall(fetchMock, 1);
expect(secondUrl).toContain("page_id=BETA");
expect(secrets.map((s) => s.name)).toEqual(["ALPHA", "BETA", "GAMMA"]);
});
it("creates a secret via direct POST /api/v1/secrets", async () => {
vi.mocked(axios.request).mockResolvedValueOnce({ data: {} });
await SecretsService.createSecret(
"OPENAI_API_KEY",
"sk-test",
"OpenAI key",
);
const [config] = vi.mocked(axios.request).mock.calls[0]!;
expect(config).toMatchObject({
url: `${cloudBackend.host}/api/v1/secrets`,
const [url, init] = getFetchCall(fetchMock);
expect(url).toBe(`${cloudBackend.host}/api/v1/secrets`);
expect(init).toMatchObject({
method: "POST",
headers: { Authorization: "Bearer bearer-token" },
data: {
name: "OPENAI_API_KEY",
value: "sk-test",
description: "OpenAI key",
},
});
expect(getJsonBody(init)).toEqual({
name: "OPENAI_API_KEY",
value: "sk-test",
description: "OpenAI key",
});
});
it("updates a secret via PUT /api/v1/secrets/{id} with name + description only", async () => {
vi.mocked(axios.request).mockResolvedValueOnce({ data: {} });
// The form/hook calls updateSecret(secretToEdit, newName, description).
await SecretsService.updateSecret("OLD_NAME", "NEW_NAME", "renamed");
const [config] = vi.mocked(axios.request).mock.calls[0]!;
expect(config).toMatchObject({
url: `${cloudBackend.host}/api/v1/secrets/OLD_NAME`,
const [url, init] = getFetchCall(fetchMock);
expect(url).toBe(`${cloudBackend.host}/api/v1/secrets/OLD_NAME`);
expect(init).toMatchObject({
method: "PUT",
headers: { Authorization: "Bearer bearer-token" },
data: { name: "NEW_NAME", description: "renamed" },
});
expect(getJsonBody(init)).toEqual({
name: "NEW_NAME",
description: "renamed",
});
});
it("deletes a secret via direct DELETE /api/v1/secrets/{id}", async () => {
vi.mocked(axios.request).mockResolvedValueOnce({ data: {} });
await SecretsService.deleteSecret("token with space");
const [config] = vi.mocked(axios.request).mock.calls[0]!;
expect(config).toMatchObject({
url: `${cloudBackend.host}/api/v1/secrets/token%20with%20space`,
const [url, init] = getFetchCall(fetchMock);
expect(url).toBe(
`${cloudBackend.host}/api/v1/secrets/token%20with%20space`,
);
expect(init).toMatchObject({
method: "DELETE",
headers: { Authorization: "Bearer bearer-token" },
});
});
it("treats a delete 404 as success (secret already gone)", async () => {
// Fresh Response per attempt: the retry helper re-fetches and a
// Response body can only be consumed once. Fake timers skip the
// retry backoff sleeps.
fetchMock.mockImplementation(() =>
Promise.resolve(mockJsonResponse({ detail: "Secret not found" }, 404)),
);
vi.useFakeTimers();
try {
const assertion = expect(
SecretsService.deleteSecret("ALREADY_GONE"),
).resolves.toBeUndefined();
await vi.runAllTimersAsync();
await assertion;
} finally {
vi.useRealTimers();
}
});
});
+37 -44
View File
@@ -1,4 +1,3 @@
import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
@@ -11,8 +10,11 @@ import {
saveCloudSettings,
} from "#/api/cloud/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
vi.mock("axios");
import {
getFetchCall,
getJsonBody,
mockJsonResponse,
} from "./fetch-test-utils";
const cloudBackend: Backend = {
id: "prod",
@@ -22,23 +24,30 @@ const cloudBackend: Backend = {
kind: "cloud",
};
const originalFetch = global.fetch;
const fetchMock = vi.fn();
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
vi.mocked(axios.request).mockReset();
fetchMock.mockReset();
fetchMock.mockResolvedValue(mockJsonResponse({}));
global.fetch = fetchMock as typeof fetch;
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
fetchMock.mockReset();
global.fetch = originalFetch;
});
describe("cloud settings", () => {
it("fetchCloudSettings preserves provider_tokens_set so the repo chain can fire", async () => {
vi.mocked(axios.request).mockResolvedValue({
data: {
fetchMock.mockResolvedValueOnce(
mockJsonResponse({
llm_model: "anthropic/claude-3-5-sonnet",
llm_base_url: "https://api.anthropic.com",
llm_api_key_set: true,
@@ -47,14 +56,14 @@ describe("cloud settings", () => {
security_analyzer: "llm",
max_iterations: 30,
provider_tokens_set: { github: "***" },
},
});
}),
);
const result = await fetchCloudSettings();
const [config] = vi.mocked(axios.request).mock.calls[0]!;
expect(config).toMatchObject({
url: `${cloudBackend.host}/api/v1/settings`,
const [url, init] = getFetchCall(fetchMock);
expect(url).toBe(`${cloudBackend.host}/api/v1/settings`);
expect(init).toMatchObject({
method: "GET",
headers: { Authorization: "Bearer bearer-token" },
});
@@ -80,8 +89,6 @@ describe("cloud settings", () => {
});
it("saveCloudSettings forwards diffs verbatim and omits the legacy keys the cloud rejects", async () => {
vi.mocked(axios.request).mockResolvedValue({ data: {} });
const agentDiff = {
llm: { model: "openai/gpt-4o", base_url: "https://api.openai.com" },
agent: "CodeActAgent",
@@ -93,13 +100,13 @@ describe("cloud settings", () => {
conversation_settings_diff: conversationDiff,
});
const [config] = vi.mocked(axios.request).mock.calls[0]!;
expect(config).toMatchObject({
url: `${cloudBackend.host}/api/v1/settings`,
const [url, init] = getFetchCall(fetchMock);
expect(url).toBe(`${cloudBackend.host}/api/v1/settings`);
expect(init).toMatchObject({
method: "POST",
headers: { Authorization: "Bearer bearer-token" },
});
const requestBody = (config as { data: Record<string, unknown> }).data;
const requestBody = getJsonBody(init);
expect(requestBody).toEqual({
agent_settings_diff: agentDiff,
conversation_settings_diff: conversationDiff,
@@ -109,9 +116,6 @@ describe("cloud settings", () => {
});
it("SettingsService.saveSettings forwards disabled_skills to cloud when active backend is cloud", async () => {
// Arrange: cloud backend already active via beforeEach; mock cloud response.
vi.mocked(axios.request).mockResolvedValue({ data: {} });
// Act: save a skills-only update — previously this short-circuited and
// sent nothing at all, leaving the toggle un-persisted.
await SettingsService.saveSettings({
@@ -120,21 +124,19 @@ describe("cloud settings", () => {
// Assert: a single POST /api/v1/settings reached the wire with
// disabled_skills as a top-level field.
expect(vi.mocked(axios.request)).toHaveBeenCalledTimes(1);
const [config] = vi.mocked(axios.request).mock.calls[0]!;
expect(config).toMatchObject({
url: `${cloudBackend.host}/api/v1/settings`,
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = getFetchCall(fetchMock);
expect(url).toBe(`${cloudBackend.host}/api/v1/settings`);
expect(init).toMatchObject({
method: "POST",
headers: { Authorization: "Bearer bearer-token" },
});
expect((config as { data: Record<string, unknown> }).data).toEqual({
expect(getJsonBody(init)).toEqual({
disabled_skills: ["SSH Microagent"],
});
});
it("saveCloudSettings omits an empty conversation_settings_diff (LLM-only save)", async () => {
vi.mocked(axios.request).mockResolvedValue({ data: {} });
await saveCloudSettings({
agent_settings_diff: {
llm: { model: "anthropic/claude-sonnet-4-20250514" },
@@ -142,8 +144,8 @@ describe("cloud settings", () => {
conversation_settings_diff: {},
});
const [config] = vi.mocked(axios.request).mock.calls[0]!;
const requestBody = (config as { data: Record<string, unknown> }).data;
const [, init] = getFetchCall(fetchMock);
const requestBody = getJsonBody(init);
expect(requestBody).toEqual({
agent_settings_diff: {
llm: { model: "anthropic/claude-sonnet-4-20250514" },
@@ -154,9 +156,6 @@ describe("cloud settings", () => {
describe("saveCloudSettings drops agent_context: null (agent-canvas#981)", () => {
it("strips a null agent_context while preserving sibling agent settings", async () => {
// Arrange: the cloud rejects agent_context: null against OpenHandsAgentSettings.
vi.mocked(axios.request).mockResolvedValue({ data: {} });
// Act
await saveCloudSettings({
agent_settings_diff: {
@@ -166,8 +165,8 @@ describe("saveCloudSettings drops agent_context: null (agent-canvas#981)", () =>
});
// Assert: agent_context never reaches the wire, but the real llm change does.
const [config] = vi.mocked(axios.request).mock.calls[0]!;
const requestBody = (config as { data: Record<string, unknown> }).data;
const [, init] = getFetchCall(fetchMock);
const requestBody = getJsonBody(init);
expect(requestBody).toEqual({
agent_settings_diff: {
llm: { model: "anthropic/claude-sonnet-4-20250514" },
@@ -176,32 +175,26 @@ describe("saveCloudSettings drops agent_context: null (agent-canvas#981)", () =>
});
it("preserves a null mcp_config so clearing MCP servers still round-trips", async () => {
// Arrange: mcp_config: null is an intentional "clear" signal, not an error.
vi.mocked(axios.request).mockResolvedValue({ data: {} });
// Act
await saveCloudSettings({
agent_settings_diff: { mcp_config: null },
});
// Assert: the null mcp_config must survive (don't over-strip nulls).
const [config] = vi.mocked(axios.request).mock.calls[0]!;
const requestBody = (config as { data: Record<string, unknown> }).data;
const [, init] = getFetchCall(fetchMock);
const requestBody = getJsonBody(init);
expect(requestBody).toEqual({ agent_settings_diff: { mcp_config: null } });
});
it("omits agent_settings_diff when agent_context: null is its only key", async () => {
// Arrange
vi.mocked(axios.request).mockResolvedValue({ data: {} });
// Act
await saveCloudSettings({
agent_settings_diff: { agent_context: null },
});
// Assert: nothing is left to send, so no agent_settings_diff goes on the wire.
const [config] = vi.mocked(axios.request).mock.calls[0]!;
const requestBody = (config as { data: Record<string, unknown> }).data;
const [, init] = getFetchCall(fetchMock);
const requestBody = getJsonBody(init);
expect(requestBody).toEqual({});
});
});
+24 -20
View File
@@ -1,4 +1,3 @@
import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
@@ -7,8 +6,7 @@ import {
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import SkillsService from "#/api/skills-service";
vi.mock("axios");
import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
const cloudBackend: Backend = {
id: "prod",
@@ -18,24 +16,30 @@ const cloudBackend: Backend = {
kind: "cloud",
};
const originalFetch = global.fetch;
const fetchMock = vi.fn();
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
vi.mocked(axios.request).mockReset();
fetchMock.mockReset();
global.fetch = fetchMock as typeof fetch;
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
fetchMock.mockReset();
global.fetch = originalFetch;
});
describe("SkillsService.getSkills against cloud backend", () => {
it("paginates /api/v1/skills/search directly and returns the merged list", async () => {
vi.mocked(axios.request)
.mockResolvedValueOnce({
data: {
fetchMock
.mockResolvedValueOnce(
mockJsonResponse({
items: [
{ name: "alpha", type: "knowledge", source: "global" },
{
@@ -46,31 +50,31 @@ describe("SkillsService.getSkills against cloud backend", () => {
},
],
next_page_id: "beta",
},
})
.mockResolvedValueOnce({
data: {
}),
)
.mockResolvedValueOnce(
mockJsonResponse({
items: [{ name: "gamma", type: "knowledge", source: "user" }],
next_page_id: null,
},
});
}),
);
const skills = await SkillsService.getSkills();
expect(vi.mocked(axios.request)).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
const [firstConfig] = vi.mocked(axios.request).mock.calls[0]!;
expect(firstConfig).toMatchObject({
const [firstUrl, firstInit] = getFetchCall(fetchMock, 0);
expect(firstInit).toMatchObject({
method: "GET",
headers: { Authorization: "Bearer bearer-token" },
});
expect((firstConfig as { url: string }).url).toMatch(
expect(firstUrl).toMatch(
/^https:\/\/app\.all-hands\.dev\/api\/v1\/skills\/search\?/,
);
expect((firstConfig as { url: string }).url).not.toContain("page_id=");
expect(firstUrl).not.toContain("page_id=");
const [secondConfig] = vi.mocked(axios.request).mock.calls[1]!;
expect((secondConfig as { url: string }).url).toContain("page_id=beta");
const [secondUrl] = getFetchCall(fetchMock, 1);
expect(secondUrl).toContain("page_id=beta");
expect(skills.map((s) => s.name)).toEqual(["alpha", "beta", "gamma"]);
expect(skills[1]).toMatchObject({ triggers: ["foo"] });
+16 -10
View File
@@ -1,4 +1,3 @@
import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
@@ -7,8 +6,7 @@ import {
} from "#/api/backend-registry/active-store";
import { getCloudSuggestedTasks } from "#/api/cloud/suggestions-service.api";
import type { Backend } from "#/api/backend-registry/types";
vi.mock("axios");
import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
const cloudBackend: Backend = {
id: "prod",
@@ -18,19 +16,24 @@ const cloudBackend: Backend = {
kind: "cloud",
};
const originalFetch = global.fetch;
const fetchMock = vi.fn();
beforeEach(() => {
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
vi.mocked(axios.request).mockReset();
vi.mocked(axios.request).mockResolvedValue({
data: { items: [], next_page_id: null },
});
fetchMock.mockReset();
fetchMock.mockResolvedValue(
mockJsonResponse({ items: [], next_page_id: null }),
);
global.fetch = fetchMock as typeof fetch;
});
afterEach(() => {
__resetActiveStoreForTests();
vi.mocked(axios.request).mockReset();
fetchMock.mockReset();
global.fetch = originalFetch;
});
describe("getCloudSuggestedTasks", () => {
@@ -39,8 +42,11 @@ describe("getCloudSuggestedTasks", () => {
await getCloudSuggestedTasks({ limit: 10, pageId: "p2" });
// Assert
const [config] = vi.mocked(axios.request).mock.calls[0]!;
const url = (config as { url: string }).url;
const [url, init] = getFetchCall(fetchMock);
expect(init).toMatchObject({
method: "GET",
headers: { Authorization: "Bearer bearer-token" },
});
expect(url).toContain("/api/v1/git/suggested-tasks/search");
expect(url).toContain("limit=10");
expect(url).toContain("page_id=p2");
@@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AxiosError, AxiosHeaders } from "axios";
import { HttpError } from "@openhands/typescript-client";
import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types";
import { useBashCommandLogs } from "#/hooks/query/use-bash-command-logs";
@@ -262,6 +263,103 @@ describe("useBashCommandLogs — cloud sandbox state handling", () => {
await waitFor(() => expect(result.current.error).toBeTruthy());
expect(result.current.sandboxIssue).toBeNull();
});
it.each([
{ status: 404, label: "404 not-found" },
{ status: 503, label: "503 unavailable" },
])(
"classifies shared-client HttpError $label responses as sandboxIssue=unreachable",
async ({ status }) => {
setConversation({
conversation_url: "https://runtime.example.com",
sandbox_status: "RUNNING",
});
listOutputsMock.mockRejectedValueOnce(
new HttpError(status, "Error", { detail: "sandbox gone" }),
);
const { result } = renderHook(
() =>
useBashCommandLogs({
conversationId: "conv-1",
bashCommandId: "cmd-1",
}),
{ wrapper },
);
await waitFor(() =>
expect(result.current.sandboxIssue).toBe("unreachable"),
);
expect(result.current.error).toBeNull();
},
);
it("does NOT collapse an HttpError 401 into unreachable (auth bugs surface as errors)", async () => {
setConversation({
conversation_url: "https://runtime.example.com",
sandbox_status: "RUNNING",
});
listOutputsMock.mockRejectedValueOnce(
new HttpError(401, "Unauthorized", { detail: "bad token" }),
);
const { result } = renderHook(
() =>
useBashCommandLogs({
conversationId: "conv-1",
bashCommandId: "cmd-1",
}),
{ wrapper },
);
await waitFor(() => expect(result.current.error).toBeTruthy());
expect(result.current.sandboxIssue).toBeNull();
});
it.each([
{
label: "network TypeError",
makeError: () => new TypeError("Failed to fetch"),
},
{
label: "TimeoutError abort",
makeError: () =>
Object.assign(new Error("The operation timed out"), {
name: "TimeoutError",
}),
},
{
label: "timeout wrapped with cause",
makeError: () =>
new Error("Request timeout after 30000ms", {
cause: Object.assign(new Error("timed out"), {
name: "TimeoutError",
}),
}),
},
])(
"classifies fetch transport failures ($label) as sandboxIssue=unreachable",
async ({ makeError }) => {
setConversation({
conversation_url: "https://runtime.example.com",
sandbox_status: "RUNNING",
});
listOutputsMock.mockRejectedValueOnce(makeError());
const { result } = renderHook(
() =>
useBashCommandLogs({
conversationId: "conv-1",
bashCommandId: "cmd-1",
}),
{ wrapper },
);
await waitFor(() =>
expect(result.current.sandboxIssue).toBe("unreachable"),
);
},
);
});
describe("useBashCommandLogs — local backend", () => {
@@ -4,6 +4,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter, Route, Routes } from "react-router";
import { HttpError } from "@openhands/typescript-client";
import { I18nKey } from "#/i18n/declaration";
@@ -153,6 +154,24 @@ describe("AutomationDetail — Edit is local-only", () => {
});
});
describe("AutomationDetail — not-found handling", () => {
it("renders the not-found state when the lookup rejects with an HttpError 404", async () => {
// Arrange — cloud transport 404s surface as the shared client's HttpError.
vi.mocked(AutomationService.getAutomation).mockRejectedValue(
new HttpError(404, "Not Found", { detail: "No such automation" }),
);
// Act
renderDetail();
// Assert — the 404 branch renders NotFoundState instead of the generic
// error state.
expect(
await screen.findByText(I18nKey.AUTOMATIONS$DETAIL$NOT_FOUND_TITLE),
).toBeInTheDocument();
});
});
describe("AutomationDetail — backend-change guard", () => {
it("does not fetch the automation again when the active backend changes after mount", async () => {
// Arrange — the page mounts under the local backend; the id in the URL
@@ -4,6 +4,7 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router";
import { HttpError } from "@openhands/typescript-client";
import { I18nKey } from "#/i18n/declaration";
@@ -315,6 +316,28 @@ describe("AutomationsList — Run now toasts", () => {
});
expect(displaySuccessToast).not.toHaveBeenCalled();
});
it("shows the server-provided message when the dispatch API rejects with an HttpError", async () => {
// Arrange — cloud transport failures surface as the shared client's
// HttpError, whose parsed body lives on `response`.
vi.mocked(AutomationService.dispatchAutomation).mockRejectedValue(
new HttpError(500, "Internal Server Error", {
message: "Runner quota exceeded",
}),
);
const { displayErrorToast } = await import("#/utils/custom-toast-handlers");
const user = userEvent.setup();
renderList();
await screen.findByText(automation.name);
// Act — click the row's "Run now" button.
await user.click(screen.getByTestId(`automation-run-now-${automation.id}`));
// Assert — the toast shows the body's message, not a generic fallback.
await waitFor(() => {
expect(displayErrorToast).toHaveBeenCalledWith("Runner quota exceeded");
});
});
});
describe("AutomationsList — list freshness on remount", () => {
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { AxiosError } from "axios";
import { HttpError } from "@openhands/typescript-client";
import { getApiErrorMessage } from "#/utils/api-error-message";
describe("getApiErrorMessage", () => {
it("returns the body `detail` from an HttpError when no `message` is present", () => {
// Arrange — FastAPI-style error body on the shared client's HttpError.
const error = new HttpError(422, "Unprocessable Entity", {
detail: "Automation spec is invalid",
});
// Act + Assert
expect(getApiErrorMessage(error, "fallback")).toBe(
"Automation spec is invalid",
);
});
it("returns the response body `message` from an axios error", () => {
// Arrange — local agent-server calls still reject with AxiosError.
const error = new AxiosError("Request failed with status code 500");
error.response = {
status: 500,
data: { message: "Runner exploded" },
} as never;
// Act + Assert
expect(getApiErrorMessage(error, "fallback")).toBe("Runner exploded");
});
it("returns the fallback when the error carries no usable information", () => {
expect(getApiErrorMessage(null, "fallback")).toBe("fallback");
});
});
@@ -7,7 +7,7 @@ import type { Backend } from "#/api/backend-registry/types";
import type { Automation, AutomationSpec } from "#/types/automation";
import AutomationService from "./automation-service.api";
const { localAxios, axiosRequest } = vi.hoisted(() => ({
const { localAxios, callCloudProxy } = vi.hoisted(() => ({
localAxios: {
interceptors: { request: { use: vi.fn() } },
get: vi.fn(),
@@ -15,17 +15,20 @@ const { localAxios, axiosRequest } = vi.hoisted(() => ({
patch: vi.fn(),
delete: vi.fn(),
},
axiosRequest: vi.fn(),
callCloudProxy: vi.fn(),
}));
vi.mock("axios", () => ({
default: {
create: () => localAxios,
request: axiosRequest,
post: vi.fn(),
},
}));
vi.mock("#/api/cloud/proxy", () => ({
callCloudProxy,
}));
const localBackend: Backend = {
id: "local-test",
name: "Local test backend",
@@ -169,32 +172,30 @@ describe("AutomationService.createAutomation", () => {
it("uses the selected cloud backend and organization for both requests", async () => {
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id, orgId: "org-1" });
axiosRequest
.mockResolvedValueOnce({ data: createdAutomation })
.mockResolvedValueOnce({
data: { ...createdAutomation, enabled: false },
});
callCloudProxy
.mockResolvedValueOnce(createdAutomation)
.mockResolvedValueOnce({ ...createdAutomation, enabled: false });
const created = await AutomationService.createAutomation(spec);
expect(axiosRequest).toHaveBeenNthCalledWith(
expect(callCloudProxy).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
url: `${cloudBackend.host}/api/automation/v1/preset/plugin`,
backend: cloudBackend,
method: "POST",
headers: expect.objectContaining({
Authorization: `Bearer ${cloudBackend.apiKey}`,
"X-Org-Id": "org-1",
}),
path: "/api/automation/v1/preset/plugin",
body: expect.objectContaining({ name: spec.name }),
headers: { "X-Org-Id": "org-1" },
}),
);
expect(axiosRequest).toHaveBeenNthCalledWith(
expect(callCloudProxy).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
url: `${cloudBackend.host}/api/automation/v1/created-automation`,
backend: cloudBackend,
method: "PATCH",
data: expect.objectContaining({ enabled: false }),
headers: expect.objectContaining({ "X-Org-Id": "org-1" }),
path: "/api/automation/v1/created-automation",
body: expect.objectContaining({ enabled: false }),
headers: { "X-Org-Id": "org-1" },
}),
);
expect(created.enabled).toBe(false);
+63
View File
@@ -0,0 +1,63 @@
import { CloudClient } from "@openhands/typescript-client/clients";
import {
getAgentServerBaseUrl,
getAgentServerHeaders,
} from "../agent-server-config";
import { NoBackendAvailableError } from "../agent-server-client-options";
import { getActiveBackend } from "../backend-registry/active-store";
import type { Backend } from "../backend-registry/types";
function requireCloudBackend(backend?: Backend): Backend {
if (backend) return backend;
const active = getActiveBackend().backend;
if (active.kind !== "cloud") {
throw new Error("Cloud calls require a cloud backend.");
}
return active;
}
/**
* Send `X-Org-Id` so the upstream scopes per-request to the org the user
* selected locally, instead of the user's globally-shared `current_org_id`
* on the cloud backend. Restricted to calls against the active backend: the
* selector also fans out per-backend bookkeeping calls (e.g.
* `getCloudOrganizations(b)`) that would otherwise carry the active
* backend's orgId across an unrelated API key, which the cloud backend
* rejects when api_key_org_id and X-Org-Id disagree.
*/
function activeOrgForBackend(backend: Backend): string | null {
const active = getActiveBackend();
return active.backend.id === backend.id ? active.orgId : null;
}
export function createCloudClient(backend?: Backend): CloudClient {
const target = requireCloudBackend(backend);
const proxyBaseUrl = getAgentServerBaseUrl();
const proxyHeaders = proxyBaseUrl ? getAgentServerHeaders() : {};
return new CloudClient({
host: target.host,
apiKey: target.apiKey,
orgId: activeOrgForBackend(target),
// Default request timeout in ms, matching the 30s the previous axios
// transport used for direct and proxied calls. Per-request
// `timeoutSeconds` still overrides it for direct calls.
timeout: 30_000,
...(proxyBaseUrl
? {
proxy: {
host: proxyBaseUrl,
headers: proxyHeaders,
},
}
: {}),
});
}
export function createCloudClientForRuntime(backend?: Backend): CloudClient {
const client = createCloudClient(backend);
if (!client.proxy) {
throw new NoBackendAvailableError();
}
return client;
}
+2 -2
View File
@@ -1,4 +1,4 @@
import axios from "axios";
import { HttpError } from "@openhands/typescript-client";
import { getActiveBackend } from "../backend-registry/active-store";
import type { Backend } from "../backend-registry/types";
import { callCloudProxy } from "./proxy";
@@ -75,7 +75,7 @@ export async function getCurrentCloudApiKey(
});
return { orgId: data?.org_id ?? null, isLegacyKey: false };
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 400) {
if (e instanceof HttpError && e.status === 400) {
return { orgId: null, isLegacyKey: true };
}
throw e;
+20 -116
View File
@@ -1,135 +1,39 @@
import axios from "axios";
import {
getAgentServerBaseUrl,
getAgentServerHeaders,
} from "../agent-server-config";
import { getActiveBackend } from "../backend-registry/active-store";
import { NoBackendAvailableError } from "../agent-server-client-options";
import { buildAuthHeaders } from "../backend-registry/auth";
import type { CloudRequestOptions } from "@openhands/typescript-client/clients";
import type { Backend } from "../backend-registry/types";
import { createCloudClientForRuntime, createCloudClient } from "./client";
export interface CloudProxyRequest {
/**
* Cloud backend whose bearer token authenticates the upstream call.
* `backend.host` is also the default upstream host unless `hostOverride`
* is set.
*/
backend: Backend;
/** HTTP method against the upstream host. */
method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
/** Path on the upstream host, e.g. "/api/v1/conversation/123/events/search". */
method: CloudRequestOptions["method"];
path: string;
/** Optional JSON body for non-GET methods. */
body?: unknown;
/** Extra headers merged with the auth header for the upstream call. */
headers?: Record<string, string>;
/** Override the upstream timeout, in seconds. */
timeoutSeconds?: number;
/**
* Override the upstream host. When set, the proxy targets this host
* instead of `backend.host`. Used for runtime-sandbox calls where the
* upstream lives at the conversation's runtime URL (e.g.
* `http://<id>.prod-runtime.all-hands.dev`) rather than the cloud API.
* The host must still pass the proxy's allowlist server-side.
*/
hostOverride?: string;
/**
* Auth strategy for the upstream call. Defaults to "bearer" (uses the
* cloud backend's bearer token via `buildAuthHeaders`). For
* runtime-sandbox calls, set to "session-api-key" and pass
* `sessionApiKey` — those endpoints don't accept bearer tokens, only
* `X-Session-API-Key`. "none" sends no auth header.
*/
authMode?: "bearer" | "session-api-key" | "none";
/** Required when `authMode === "session-api-key"`. */
sessionApiKey?: string | null;
/**
* Axios responseType for the inner POST to the bundled agent-server.
* Set to "blob" when the upstream cloud endpoint returns a binary
* payload (e.g. ZIP downloads); leave undefined for default JSON.
*/
responseType?: "blob";
}
function buildUpstreamAuthHeaders(
req: CloudProxyRequest,
): Record<string, string> {
const mode = req.authMode ?? "bearer";
if (mode === "bearer") return buildAuthHeaders(req.backend);
if (mode === "session-api-key") {
return req.sessionApiKey ? { "X-Session-API-Key": req.sessionApiKey } : {};
}
return {};
}
/**
* Send a cloud request. App-host calls (`backend.host`) go directly to the
* cloud API with the cloud backend's auth headers. Runtime-sandbox calls
* pass `hostOverride`, and those go through `/api/cloud-proxy` because the
* per-conversation runtime hosts are not the configured cloud app origin.
*
* App-host auth headers are sent directly to the cloud host. Proxied auth
* headers are carried in the proxy envelope and attached server-side.
*/
export async function callCloudProxy<TResponse = unknown>(
req: CloudProxyRequest,
): Promise<TResponse> {
// Send `X-Org-Id` so the upstream scopes per-request to the org the user
// selected locally, instead of the user's globally-shared
// `current_org_id` on the cloud backend. Restricted to calls against the active
// backend: the selector also fans out per-backend bookkeeping calls
// (e.g. `getCloudOrganizations(b)`) that would otherwise carry the
// active backend's orgId across an unrelated API key, which the cloud backend
// rejects when api_key_org_id and X-Org-Id disagree.
const active = getActiveBackend();
const orgIdHeader =
active.backend.id === req.backend.id && active.orgId
? { "X-Org-Id": active.orgId }
: {};
const upstreamHeaders = {
...buildUpstreamAuthHeaders(req),
...orgIdHeader,
...(req.headers ?? {}),
};
const upstreamHost = req.hostOverride ?? req.backend.host;
const client = req.hostOverride
? createCloudClientForRuntime(req.backend)
: createCloudClient(req.backend);
if (!req.hostOverride) {
const response = await axios.request<TResponse>({
url: `${upstreamHost.replace(/\/+$/, "")}${req.path}`,
method: req.method,
headers: upstreamHeaders,
...(req.body !== undefined ? { data: req.body } : {}),
timeout: (req.timeoutSeconds ?? 30) * 1000,
...(req.responseType ? { responseType: req.responseType } : {}),
});
return response.data;
}
const proxyBaseUrl = getAgentServerBaseUrl();
if (!proxyBaseUrl) throw new NoBackendAvailableError();
const localAuthHeaders = getAgentServerHeaders();
// Talk to the configured app/ingress origin that exposes /api/cloud-proxy.
// Do not resolve this through the backend registry: when the active backend
// is cloud, borrowing some other registered local backend would silently
// route cloud traffic through the wrong user-configured server.
const response = await axios.post<TResponse>(
`${proxyBaseUrl.replace(/\/+$/, "")}/api/cloud-proxy`,
{
host: upstreamHost,
method: req.method,
path: req.path,
headers: upstreamHeaders,
body: req.body ?? null,
...(req.timeoutSeconds ? { timeout_seconds: req.timeoutSeconds } : {}),
},
{
headers: localAuthHeaders,
timeout: 30_000,
...(req.responseType ? { responseType: req.responseType } : {}),
},
);
return response.data;
return client.request<TResponse>({
method: req.method,
path: req.path,
body: req.body,
headers: req.headers,
timeoutSeconds: req.timeoutSeconds,
hostOverride: req.hostOverride,
authMode:
req.authMode === undefined || req.authMode === "bearer"
? "bearer"
: req.authMode,
sessionApiKey: req.sessionApiKey,
responseType: req.responseType,
});
}
+28 -319
View File
@@ -1,334 +1,43 @@
/**
* OAuth 2.0 Device Flow client implementation (RFC 8628).
*
* Used for one-click authentication with cloud backends.
* The flow allows users to authenticate in their browser while the
* application polls for the resulting API key.
*
* Device flow requests go directly to the configured OpenHands Cloud host.
*/
export {
DeviceFlowError,
pollForToken,
startDeviceFlow,
} from "@openhands/typescript-client/clients";
import { isOpenHandsCloudHost as sdkIsOpenHandsCloudHost } from "@openhands/typescript-client/clients";
export class DeviceFlowError extends Error {
constructor(
message: string,
public readonly code?: string,
) {
super(message);
this.name = "DeviceFlowError";
}
const OPENHANDS_CLOUD_HOST_SUFFIXES = ["all-hands.dev", "openhands.dev"];
function isAllowedCloudHostname(hostname: string): boolean {
return OPENHANDS_CLOUD_HOST_SUFFIXES.some(
(suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`),
);
}
export interface DeviceAuthorizationResponse {
device_code: string;
user_code: string;
verification_uri: string;
verification_uri_complete: string;
expires_in: number;
interval: number;
}
function fallbackIsOpenHandsCloudHost(host: string): boolean {
if (!host.trim()) return false;
export interface DeviceTokenResponse {
access_token: string;
token_type: string;
expires_in?: number;
}
interface DeviceTokenErrorResponse {
error: string;
error_description?: string;
interval?: number;
}
const DEFAULT_TIMEOUT_MS = 600_000; // 10 minutes
const MAX_INTERVAL_MS = 30_000; // 30 seconds max polling interval
/**
* Check if a host is a known OpenHands Cloud domain.
* Uses hostname extraction to prevent substring matching attacks.
*/
export function isOpenHandsCloudHost(host: string): boolean {
try {
// Extract hostname from URL or treat as hostname if no protocol
const trimmed = host.trim().toLowerCase();
const withProtocol = /^https?:\/\//i.test(trimmed)
? trimmed
: `https://${trimmed}`;
const url = new URL(withProtocol);
const hostname = url.hostname;
// Check if hostname ends with known domains (exact suffix match)
return (
hostname.endsWith(".all-hands.dev") ||
hostname === "all-hands.dev" ||
hostname.endsWith(".openhands.dev") ||
hostname === "openhands.dev"
);
const normalizedHost = host.includes("://") ? host : `https://${host}`;
const { hostname } = new URL(normalizedHost);
return isAllowedCloudHostname(hostname.toLowerCase());
} catch {
return false;
}
}
/**
* Make a direct request to the OpenHands Cloud device-flow endpoint.
*/
async function makeCloudRequest(
upstreamHost: string,
method: "GET" | "POST",
path: string,
body?: unknown,
contentType?: string,
signal?: AbortSignal,
): Promise<Response> {
const requestBody =
body === undefined
? undefined
: typeof body === "string" ||
body instanceof Blob ||
body instanceof FormData ||
body instanceof URLSearchParams
? body
: JSON.stringify(body);
const response = await fetch(`${upstreamHost.replace(/\/+$/, "")}${path}`, {
method,
headers: {
...(contentType ? { "Content-Type": contentType } : {}),
},
body: requestBody,
signal,
});
return response;
}
/**
* Start the OAuth 2.0 Device Flow by requesting a device code.
* Requests are sent directly to the cloud host.
*
* @param host - The cloud backend host URL (e.g., "https://app.all-hands.dev")
* @returns DeviceAuthorizationResponse with device_code, user_code, verification URLs, etc.
* @throws DeviceFlowError if the request fails
*/
export async function startDeviceFlow(
host: string,
): Promise<DeviceAuthorizationResponse> {
const normalizedHost = host.replace(/\/+$/, "");
export function isOpenHandsCloudHost(host: string): boolean {
try {
const response = await makeCloudRequest(
normalizedHost,
"POST",
"/oauth/device/authorize",
{},
"application/json",
);
if (!response.ok) {
// Avoid exposing sensitive server error details
throw new DeviceFlowError(
`Failed to start device flow: Server returned ${response.status}`,
);
}
const data = await response.json();
// Validate required fields per RFC 8628 Section 3.2
// verification_uri_complete is OPTIONAL per RFC
if (!data.device_code || !data.user_code || !data.verification_uri) {
throw new DeviceFlowError(
"Invalid response from device authorization endpoint: missing required fields",
);
}
// Build verification_uri_complete if not provided (optional per RFC)
const verificationUriComplete =
data.verification_uri_complete ??
`${data.verification_uri}?user_code=${encodeURIComponent(data.user_code)}`;
return {
device_code: data.device_code,
user_code: data.user_code,
verification_uri: data.verification_uri,
verification_uri_complete: verificationUriComplete,
expires_in: data.expires_in ?? 600,
interval: data.interval ?? 5,
};
} catch (error) {
if (error instanceof DeviceFlowError) {
throw error;
}
throw new DeviceFlowError(
`Failed to start device flow: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
export interface PollOptions {
/** Polling interval in seconds (from device authorization response) */
interval: number;
/** Maximum time to wait for authorization in milliseconds */
timeout?: number;
/** Abort signal to cancel polling */
signal?: AbortSignal;
}
/**
* Poll for the API key after user authorization.
* Requests are sent directly to the cloud host.
*
* @param host - The cloud backend host URL
* @param deviceCode - The device code from startDeviceFlow
* @param options - Polling options including interval, timeout, and abort signal
* @returns DeviceTokenResponse containing the access_token (API key)
* @throws DeviceFlowError if polling fails, user denies access, or timeout expires
*/
export async function pollForToken(
host: string,
deviceCode: string,
options: PollOptions,
): Promise<DeviceTokenResponse> {
const normalizedHost = host.replace(/\/+$/, "");
const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
let interval = Math.max(1, options.interval) * 1000; // At least 1 second
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
// Check if cancelled
if (options.signal?.aborted) {
throw new DeviceFlowError("Authorization cancelled", "cancelled");
}
try {
// RFC 8628 Section 3.4 requires grant_type parameter
const tokenRequestBody = new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: deviceCode,
}).toString();
const response = await makeCloudRequest(
normalizedHost,
"POST",
"/oauth/device/token",
tokenRequestBody,
"application/x-www-form-urlencoded",
options.signal,
);
if (response.ok) {
const data = await response.json();
if (!data.access_token) {
throw new DeviceFlowError(
"Invalid token response: missing access_token",
);
}
return {
access_token: data.access_token,
token_type: data.token_type ?? "Bearer",
expires_in: data.expires_in,
};
}
// Handle error responses
let errorData: DeviceTokenErrorResponse;
try {
errorData = await response.json();
} catch {
throw new DeviceFlowError(
`Unexpected response from server: ${response.status}`,
);
}
const { error, error_description } = errorData;
switch (error) {
case "authorization_pending":
// User hasn't finished yet; continue polling
break;
case "slow_down":
// Server asks us to poll less frequently
// RFC 8628 Section 3.5: "the client MUST increase its polling interval by 5 seconds"
// Validate server-provided interval to prevent DoS (must be number, finite, positive)
if (
typeof errorData.interval === "number" &&
isFinite(errorData.interval) &&
errorData.interval > 0
) {
interval = Math.max(1, Math.min(errorData.interval, 30)) * 1000;
} else {
// RFC 8628 mandates incrementing by 5 seconds
interval = Math.min(interval + 5000, MAX_INTERVAL_MS);
}
break;
case "expired_token":
throw new DeviceFlowError(
"Device code has expired. Please try again.",
"expired_token",
);
case "access_denied":
throw new DeviceFlowError(
"Authorization request was denied.",
"access_denied",
);
default:
throw new DeviceFlowError(
`Authorization error: ${error}${error_description ? ` - ${error_description}` : ""}`,
error,
);
}
} catch (error) {
// DeviceFlowError means a definitive error (denied, expired, etc.) - rethrow
if (error instanceof DeviceFlowError) {
throw error;
}
// User cancelled - rethrow
if (error instanceof DOMException && error.name === "AbortError") {
throw new DeviceFlowError("Authorization cancelled", "cancelled");
}
// Network errors during polling should continue until timeout, not fail immediately
// Brief network hiccups shouldn't abort 10-minute flows
console.warn("Network error during polling, retrying:", error);
}
// Wait before next poll (wrap in try-catch for consistent abort handling)
try {
await sleep(interval, options.signal);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
throw new DeviceFlowError("Authorization cancelled", "cancelled");
}
throw error;
if (typeof sdkIsOpenHandsCloudHost === "function") {
return sdkIsOpenHandsCloudHost(host);
}
} catch {
return fallbackIsOpenHandsCloudHost(host);
}
throw new DeviceFlowError(
"Timeout waiting for authorization. Please try again.",
"timeout",
);
}
/**
* Sleep for a given duration, respecting an optional abort signal.
*/
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new DOMException("Aborted", "AbortError"));
return;
}
const timeoutId = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timeoutId);
reject(new DOMException("Aborted", "AbortError"));
},
{ once: true },
);
});
return fallbackIsOpenHandsCloudHost(host);
}
export type {
DeviceAuthorizationResponse,
DeviceTokenResponse,
PollDeviceTokenOptions as PollOptions,
} from "@openhands/typescript-client/clients";
@@ -1,5 +1,5 @@
import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace";
import { isAxiosError } from "axios";
import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace";
import { mapAnyGitStatusToClientStatus } from "#/utils/git-status-mapper";
import { buildHttpBaseUrl } from "#/utils/websocket-url";
import type {
@@ -97,8 +97,9 @@ async function getFromRuntime<T>(
/**
* The commit endpoints are newer than some deployed agent servers. A 404
* (route absent) means "server too old" — callers hide the commits section
* instead of surfacing an error. Local calls throw the SDK's `HttpError`;
* cloud-proxy calls throw an `AxiosError`.
* instead of surfacing an error. Local calls and cloud-proxy calls (via the
* shared TypeScript client) both throw the SDK's `HttpError`; the axios
* check is kept as a safety net for axios-shaped errors.
*/
function isEndpointMissingError(error: unknown): boolean {
return (
+10 -5
View File
@@ -1,4 +1,5 @@
import { SettingsClient } from "@openhands/typescript-client/clients";
import { isSdkHttpStatusError } from "./agent-server-compatibility";
import { getActiveBackend } from "./backend-registry/active-store";
import {
createCloudSecret,
@@ -132,12 +133,16 @@ export class SecretsService {
new SettingsClient(getAgentServerClientOptions()).deleteSecret(name),
);
} catch (error) {
// 404 means secret doesn't exist - treat as successful deletion
// 404 means secret doesn't exist - treat as successful deletion.
// Both the SDK's HttpError (status on the error itself) and
// axios-style errors (status under `response`) count.
if (
error &&
typeof error === "object" &&
"response" in error &&
(error as { response?: { status?: number } }).response?.status === 404
isSdkHttpStatusError(error, 404) ||
(error &&
typeof error === "object" &&
"response" in error &&
(error as { response?: { status?: number } }).response?.status ===
404)
) {
return;
}
@@ -1,6 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { isAxiosError } from "axios";
import { I18nKey } from "#/i18n/declaration";
import type { Automation } from "#/types/automation";
import { useUpdateAutomation } from "#/hooks/query/use-automations";
@@ -12,6 +11,7 @@ import {
displaySuccessToast,
displayErrorToast,
} from "#/utils/custom-toast-handlers";
import { getApiErrorMessage } from "#/utils/api-error-message";
import { modalTitleLgMediumClassName } from "#/utils/modal-classes";
import {
parseCronSchedule,
@@ -246,13 +246,9 @@ export function EditAutomationModal({
onClose();
},
onError: (error) => {
const message = isAxiosError(error)
? (error.response?.data as { message?: string } | undefined)
?.message ||
error.message ||
t(I18nKey.AUTOMATIONS$EDIT_ERROR)
: (error as Error).message || t(I18nKey.AUTOMATIONS$EDIT_ERROR);
displayErrorToast(message);
displayErrorToast(
getApiErrorMessage(error, t(I18nKey.AUTOMATIONS$EDIT_ERROR)),
);
},
},
);
+5 -1
View File
@@ -1,6 +1,7 @@
import React from "react";
import axios from "axios";
import { useQueries } from "@tanstack/react-query";
import { HttpError } from "@openhands/typescript-client";
import {
ServerClient,
SettingsClient,
@@ -85,7 +86,10 @@ async function probeBackend(backend: Backend): Promise<true> {
try {
await getCurrentCloudApiKey(backend);
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
if (
(axios.isAxiosError(error) && error.response?.status === 401) ||
(error instanceof HttpError && error.status === 401)
) {
throw new Error(CLOUD_BACKEND_LOGGED_OUT_ERROR);
}
if (isCorsOrNetworkError(error)) {
+37 -13
View File
@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { isSdkHttpError } from "#/api/agent-server-compatibility";
import BashService from "#/api/bash-service/bash-service.api";
import { useActiveBackend } from "#/contexts/active-backend-context";
import type { SandboxStatus } from "#/api/conversation-service/agent-server-conversation-service.types";
@@ -56,21 +57,44 @@ function sandboxIssueFromStatus(
}
/**
* Detect "the runtime is unreachable" axios errors from the cloud
* proxy. The proxy itself returns 5xx when the upstream sandbox is
* gone; runtimes return 4xx/5xx for various ephemeral states. We
* classify 5xx and network errors as "unreachable" so the modal can
* render the sandbox-gone state instead of dumping an axios string.
* Detect "the runtime is unreachable" errors from the cloud proxy. The
* proxy itself returns 5xx when the upstream sandbox is gone; runtimes
* return 4xx/5xx for various ephemeral states. We classify 5xx and
* network errors as "unreachable" so the modal can render the
* sandbox-gone state instead of dumping a raw error. Cloud calls go
* through the shared TypeScript client and throw its `HttpError`;
* axios-shaped errors are still recognized as well.
*/
function classifyFetchError(error: unknown): SandboxIssue | null {
if (!axios.isAxiosError(error)) return null;
// No response at all → DNS/connect/abort
if (!error.response) return "unreachable";
const status = error.response.status;
// Treat 502/503/504 (proxy can't reach upstream) and 404 (sandbox or
// resource no longer exists) as the sandbox being gone. We do not
// collapse 401/403 here — those are auth bugs we want to surface.
if (status === 404 || status >= 500) return "unreachable";
const status = axios.isAxiosError(error)
? error.response?.status
: isSdkHttpError(error)
? (error as { status: number }).status
: undefined;
if (status !== undefined) {
// Treat 502/503/504 (proxy can't reach upstream) and 404 (sandbox or
// resource no longer exists) as the sandbox being gone. We do not
// collapse 401/403 here — those are auth bugs we want to surface.
return status === 404 || status >= 500 ? "unreachable" : null;
}
// No status → the request never got a response. Axios reports these as
// response-less errors; fetch (the shared client) throws `TypeError`
// for network failures and `AbortError`/`TimeoutError` for timeouts,
// sometimes wrapped in a plain `Error` with the original as `cause`.
if (axios.isAxiosError(error) || error instanceof TypeError) {
return "unreachable";
}
if (error instanceof Error) {
const causeName = error.cause instanceof Error ? error.cause.name : null;
if (
error.name === "AbortError" ||
error.name === "TimeoutError" ||
causeName === "AbortError" ||
causeName === "TimeoutError"
) {
return "unreachable";
}
}
return null;
}
+6 -10
View File
@@ -1,12 +1,13 @@
import { useRef, useState } from "react";
import { useParams } from "react-router";
import { isAxiosError } from "axios";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import {
displaySuccessToast,
displayErrorToast,
} from "#/utils/custom-toast-handlers";
import { getApiErrorMessage } from "#/utils/api-error-message";
import { getErrorStatus } from "#/hooks/query/use-settings";
import { useAutomationDetail } from "#/hooks/query/use-automation-detail";
import {
useToggleAutomation,
@@ -79,8 +80,7 @@ export default function AutomationDetail() {
const deleteMutation = useDeleteAutomation();
const dispatchMutation = useDispatchAutomation();
const is404 =
isError && isAxiosError(error) && error.response?.status === 404;
const is404 = isError && getErrorStatus(error) === 404;
// Show loading state while checking health
if (isHealthLoading) {
@@ -159,13 +159,9 @@ export default function AutomationDetail() {
displaySuccessToast(t(I18nKey.AUTOMATIONS$RUN_NOW_SUCCESS));
},
onError: (error) => {
const message = isAxiosError(error)
? (error.response?.data as { message?: string } | undefined)
?.message ||
error.message ||
t(I18nKey.AUTOMATIONS$RUN_NOW_ERROR)
: (error as Error).message || t(I18nKey.AUTOMATIONS$RUN_NOW_ERROR);
displayErrorToast(message);
displayErrorToast(
getApiErrorMessage(error, t(I18nKey.AUTOMATIONS$RUN_NOW_ERROR)),
);
},
});
};
+7 -18
View File
@@ -7,13 +7,13 @@ import {
} from "react";
import { FileUp } from "lucide-react";
import { useTranslation } from "react-i18next";
import { isAxiosError } from "axios";
import { I18nKey } from "#/i18n/declaration";
import {
displaySuccessToast,
displaySuccessToastWithLink,
displayErrorToast,
} from "#/utils/custom-toast-handlers";
import { getApiErrorMessage } from "#/utils/api-error-message";
import {
useAutomations,
useToggleAutomation,
@@ -134,13 +134,9 @@ export default function AutomationsList() {
displaySuccessToast(t(I18nKey.AUTOMATIONS$RUN_NOW_SUCCESS));
},
onError: (error) => {
const message = isAxiosError(error)
? (error.response?.data as { message?: string } | undefined)
?.message ||
error.message ||
t(I18nKey.AUTOMATIONS$RUN_NOW_ERROR)
: (error as Error).message || t(I18nKey.AUTOMATIONS$RUN_NOW_ERROR);
displayErrorToast(message);
displayErrorToast(
getApiErrorMessage(error, t(I18nKey.AUTOMATIONS$RUN_NOW_ERROR)),
);
},
});
};
@@ -205,16 +201,9 @@ export default function AutomationsList() {
);
},
onError: (error) => {
const detail = isAxiosError(error)
? (error.response?.data as { detail?: unknown } | undefined)?.detail
: undefined;
const message =
typeof detail === "string"
? detail
: isAxiosError(error)
? error.message
: t(I18nKey.ERROR$GENERIC);
displayErrorToast(message);
displayErrorToast(
getApiErrorMessage(error, t(I18nKey.ERROR$GENERIC)),
);
},
},
);
+31
View File
@@ -0,0 +1,31 @@
import axios from "axios";
/**
* Extract a human-readable message from a failed API call.
*
* Handles both transports the app uses: local agent-server calls that
* throw an `AxiosError` (body under `error.response.data`) and cloud
* calls through the shared TypeScript client that throw an `HttpError`
* (parsed body directly under `error.response`). Prefers the
* server-provided `message`/`detail` fields, then the `Error` message,
* then `fallback`.
*/
export function getApiErrorMessage(error: unknown, fallback: string): string {
const body = axios.isAxiosError(error)
? error.response?.data
: error instanceof Error && "response" in error
? (error as { response?: unknown }).response
: undefined;
if (body && typeof body === "object") {
const { message, detail } = body as {
message?: unknown;
detail?: unknown;
};
if (typeof message === "string" && message) return message;
if (typeof detail === "string" && detail) return detail;
}
if (error instanceof Error && error.message) return error.message;
return fallback;
}