diff --git a/__tests__/api/agent-server-git-service.test.ts b/__tests__/api/agent-server-git-service.test.ts index 04bd949d21..d47c23e745 100644 --- a/__tests__/api/agent-server-git-service.test.ts +++ b/__tests__/api/agent-server-git-service.test.ts @@ -1,5 +1,12 @@ import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace"; -import { describe, test, expect, vi, beforeEach } from "vitest"; +import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; +import { + __resetActiveStoreForTests, + setActiveSelection, + setRegisteredBackends, +} from "#/api/backend-registry/active-store"; +import { callCloudProxy } from "#/api/cloud/proxy"; +import type { Backend } from "#/api/backend-registry/types"; import AgentServerGitService from "../../src/api/git-service/agent-server-git-service.api"; const { mockGitChanges, mockGitDiff } = vi.hoisted(() => ({ @@ -16,6 +23,10 @@ vi.mock("@openhands/typescript-client/workspace/remote-workspace", () => ({ }), })); +vi.mock("#/api/cloud/proxy", () => ({ + callCloudProxy: vi.fn(), +})); + describe("AgentServerGitService", () => { beforeEach(() => { vi.clearAllMocks(); @@ -30,6 +41,7 @@ describe("AgentServerGitService", () => { await expect( AgentServerGitService.getGitChanges( + "123", "http://localhost:3000/api/conversations/123", "test-api-key", "/workspace", @@ -41,6 +53,7 @@ describe("AgentServerGitService", () => { mockGitChanges.mockResolvedValue([]); await AgentServerGitService.getGitChanges( + "123", "http://localhost:3000/api/conversations/123", "my-session-key", "/workspace/project", @@ -61,6 +74,7 @@ describe("AgentServerGitService", () => { const pathWithSlashes = "/workspace/project/src/components"; await AgentServerGitService.getGitChanges( + "123", "http://localhost:3000/api/conversations/123", "test-api-key", pathWithSlashes, @@ -80,6 +94,7 @@ describe("AgentServerGitService", () => { ]); const result = await AgentServerGitService.getGitChanges( + "123", "http://localhost:3000/api/conversations/123", "test-api-key", "/workspace", @@ -101,6 +116,7 @@ describe("AgentServerGitService", () => { }); await AgentServerGitService.getGitChangeDiff( + "123", "http://localhost:3000/api/conversations/123", "test-api-key", "/workspace/project/file.ts", @@ -121,6 +137,7 @@ describe("AgentServerGitService", () => { const filePath = "/workspace/project/src/components/Button.tsx"; await AgentServerGitService.getGitChangeDiff( + "123", "http://localhost:3000/api/conversations/123", "test-api-key", filePath, @@ -136,6 +153,7 @@ describe("AgentServerGitService", () => { mockGitDiff.mockResolvedValue(expectedDiff); const result = await AgentServerGitService.getGitChangeDiff( + "123", "http://localhost:3000/api/conversations/123", "test-api-key", "/workspace/file.ts", @@ -148,4 +166,124 @@ describe("AgentServerGitService", () => { }); }); }); + + describe("cloud backend", () => { + const cloudBackend: Backend = { + id: "cloud-1", + name: "Production", + host: "https://app.all-hands.dev", + apiKey: "cloud-key", + kind: "cloud", + }; + + const runtimeConversationUrl = + "https://abc123.prod-runtime.all-hands.dev/api/conversations/conv-1"; + + beforeEach(() => { + window.localStorage.clear(); + __resetActiveStoreForTests(); + setRegisteredBackends([cloudBackend]); + setActiveSelection({ backendId: cloudBackend.id, orgId: "org-1" }); + vi.mocked(callCloudProxy).mockReset(); + }); + + afterEach(() => { + window.localStorage.clear(); + __resetActiveStoreForTests(); + }); + + describe("getGitChanges", () => { + test("fetches changes via the cloud app-conversations git endpoint and maps statuses", async () => { + // Arrange + vi.mocked(callCloudProxy).mockResolvedValue([ + { status: "ADDED", path: "new-file.ts" }, + { status: "UPDATED", path: "changed-file.ts" }, + ]); + + // Act + const result = await AgentServerGitService.getGitChanges( + "conv-1", + runtimeConversationUrl, + "session-key", + "workspace/project", + ); + + // Assert — addressed by conversation id on the cloud API itself + // (no hostOverride / session-api-key runtime hop), with the + // relative git path normalized to an absolute runtime path. + expect(callCloudProxy).toHaveBeenCalledWith({ + backend: cloudBackend, + method: "GET", + path: "/api/v1/app-conversations/conv-1/git/changes?path=%2Fworkspace%2Fproject", + }); + expect(result).toEqual([ + { status: "A", path: "new-file.ts" }, + { status: "M", path: "changed-file.ts" }, + ]); + }); + + test("throws when the cloud endpoint returns a non-array response", async () => { + // Arrange — a dead runtime can surface as a non-JSON-array body. + vi.mocked(callCloudProxy).mockResolvedValue( + "...", + ); + + // Act + Assert + await expect( + AgentServerGitService.getGitChanges( + "conv-1", + runtimeConversationUrl, + "session-key", + "workspace/project", + ), + ).rejects.toThrow("Invalid response from runtime"); + }); + }); + + describe("getGitChangeDiff", () => { + test("fetches the diff via the cloud app-conversations git endpoint", async () => { + // Arrange + vi.mocked(callCloudProxy).mockResolvedValue({ + original: "old content", + modified: "new content", + }); + + // Act + const result = await AgentServerGitService.getGitChangeDiff( + "conv-1", + runtimeConversationUrl, + "session-key", + "/workspace/project/src/file.ts", + ); + + // Assert + expect(callCloudProxy).toHaveBeenCalledWith({ + backend: cloudBackend, + method: "GET", + path: "/api/v1/app-conversations/conv-1/git/diff?path=%2Fworkspace%2Fproject%2Fsrc%2Ffile.ts", + }); + expect(result).toEqual({ + original: "old content", + modified: "new content", + }); + }); + }); + + test("does not touch the runtime workspace SDK on cloud backends", async () => { + // Arrange + vi.mocked(callCloudProxy).mockResolvedValue([]); + + // Act + await AgentServerGitService.getGitChanges( + "conv-1", + runtimeConversationUrl, + "session-key", + "workspace/project", + ); + + // Assert — the conversation's runtime URL must no longer be dialed + // from the browser; the cloud API makes the runtime hop server-side. + expect(RemoteWorkspace).not.toHaveBeenCalled(); + }); + }); }); diff --git a/__tests__/api/mock-conversation-handlers.test.ts b/__tests__/api/mock-conversation-handlers.test.ts index 8c9d530df9..c72b590e4d 100644 --- a/__tests__/api/mock-conversation-handlers.test.ts +++ b/__tests__/api/mock-conversation-handlers.test.ts @@ -27,6 +27,7 @@ describe("mock conversation handlers", () => { // representative entries (UPDATED→M, ADDED→A, DELETED→D) so E2E snapshot // tests can exercise the full diff-viewer UI without per-test manipulation. const changes = await AgentServerGitService.getGitChanges( + "1", "http://localhost:3000/api/conversations/1", null, "workspace/project", diff --git a/src/api/git-service/agent-server-git-service.api.ts b/src/api/git-service/agent-server-git-service.api.ts index 49d0bdcbbd..907e8cab19 100644 --- a/src/api/git-service/agent-server-git-service.api.ts +++ b/src/api/git-service/agent-server-git-service.api.ts @@ -1,6 +1,5 @@ import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace"; import { mapAnyGitStatusToClientStatus } from "#/utils/git-status-mapper"; -import { buildHttpBaseUrl } from "#/utils/websocket-url"; import type { GitChange, GitChangeDiff } from "../open-hands.types"; import { getActiveBackend } from "../backend-registry/active-store"; import { callCloudProxy } from "../cloud/proxy"; @@ -18,9 +17,11 @@ interface AgentServerGitChange { * (it's `127.0.0.1:18000`); the SDK's `RemoteWorkspace` calls land * fine. In **cloud** mode the runtime is at * `*.prod-runtime.all-hands.dev`, which doesn't allow CORS from - * `localhost`. So cloud-mode calls go through `callCloudProxy` with the - * runtime URL as `hostOverride` and the conversation's - * `session_api_key` as the auth header — server-side hop, no CORS. + * `localhost`. So cloud-mode calls hit the cloud API's + * `GET /api/v1/app-conversations/{id}/git/{changes,diff}` proxy + * endpoints instead — the server resolves the conversation's runtime + * and makes the hop itself with the sandbox's session API key, and the + * cloud API's CORS is permissive for bearer-token requests. */ /** @@ -37,22 +38,20 @@ function toAbsoluteRuntimePath(path: string): string { class AgentServerGitService { static async getGitChanges( + conversationId: string, conversationUrl: string | null | undefined, sessionApiKey: string | null | undefined, path: string, ): Promise { const active = getActiveBackend().backend; - if (active.kind === "cloud" && conversationUrl) { + if (active.kind === "cloud" && conversationId) { const params = new URLSearchParams(); params.set("path", toAbsoluteRuntimePath(path)); const data = await callCloudProxy({ backend: active, method: "GET", - hostOverride: buildHttpBaseUrl(conversationUrl), - path: `/api/git/changes?${params.toString()}`, - authMode: "session-api-key", - sessionApiKey, + path: `/api/v1/app-conversations/${conversationId}/git/changes?${params.toString()}`, }); if (!Array.isArray(data)) { throw new Error( @@ -90,22 +89,20 @@ class AgentServerGitService { } static async getGitChangeDiff( + conversationId: string, conversationUrl: string | null | undefined, sessionApiKey: string | null | undefined, path: string, ): Promise { const active = getActiveBackend().backend; - if (active.kind === "cloud" && conversationUrl) { + if (active.kind === "cloud" && conversationId) { const params = new URLSearchParams(); params.set("path", toAbsoluteRuntimePath(path)); const diff = await callCloudProxy({ backend: active, method: "GET", - hostOverride: buildHttpBaseUrl(conversationUrl), - path: `/api/git/diff?${params.toString()}`, - authMode: "session-api-key", - sessionApiKey, + path: `/api/v1/app-conversations/${conversationId}/git/diff?${params.toString()}`, }); return { modified: diff?.modified ?? "", diff --git a/src/hooks/query/use-unified-get-git-changes.ts b/src/hooks/query/use-unified-get-git-changes.ts index 09c86c43ab..805ffd759b 100644 --- a/src/hooks/query/use-unified-get-git-changes.ts +++ b/src/hooks/query/use-unified-get-git-changes.ts @@ -36,6 +36,7 @@ export const useUnifiedGetGitChanges = () => { if (!conversationId) throw new Error("No conversation ID"); return AgentServerGitService.getGitChanges( + conversationId, conversationUrl, sessionApiKey, gitPath, diff --git a/src/hooks/query/use-unified-git-diff.ts b/src/hooks/query/use-unified-git-diff.ts index 354ba8999d..1d3aafe8a9 100644 --- a/src/hooks/query/use-unified-git-diff.ts +++ b/src/hooks/query/use-unified-git-diff.ts @@ -44,6 +44,7 @@ export const useUnifiedGitDiff = (config: UseUnifiedGitDiffConfig) => { if (!conversationId) throw new Error("No conversation ID"); return AgentServerGitService.getGitChangeDiff( + conversationId, conversationUrl, sessionApiKey, absoluteFilePath,