File size: 7,937 Bytes
3201ca6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
  buildWorkspaceUploadPath,
  getSafeUploadFileName,
  resolveAbsoluteWorkspacePath,
  resolveConversationUploadWorkingDir,
} from "#/api/workspace-upload-path";
import { clearAgentServerHomeDirCache } from "#/api/agent-server-home";
import { DEFAULT_WORKING_DIR } from "#/api/agent-server-config";
import {
  removeStoredConversationMetadata,
  setStoredConversationMetadata,
} from "#/api/conversation-metadata-store";

const mockGetHome = vi.fn();

vi.mock("@openhands/typescript-client/clients", () => ({
  FileClient: vi.fn(function FileClientMock() {
    return { getHome: mockGetHome };
  }),
}));

vi.mock("#/api/agent-server-client-options", () => ({
  getAgentServerClientOptions: vi.fn(() => ({
    host: "http://localhost:8000",
    apiKey: "test-key",
    workingDir: "workspace/project",
  })),
}));

vi.mock(
  "#/api/conversation-service/agent-server-conversation-service.api",
  () => ({
    default: {
      resolveConversationWorkingDir: vi.fn(
        async (id: string) => `/workspace/project/${id.replace(/-/g, "")}`,
      ),
    },
  }),
);

beforeEach(() => {
  clearAgentServerHomeDirCache();
  mockGetHome.mockReset();
  mockGetHome.mockResolvedValue({ home: "/Users/test" });
});

describe("workspace-upload-path", () => {
  // @spec WUP-001 — resolver anchors relative paths against /api/file/home.
  it("resolveAbsoluteWorkspacePath joins relative dirs to the agent-server home", async () => {
    const resolved = await resolveAbsoluteWorkspacePath("workspace/project");
    expect(resolved).toBe("/Users/test/workspace/project");
    expect(mockGetHome).toHaveBeenCalledTimes(1);
  });

  // @spec WUP-001 — absolute inputs pass through, no /file/home round-trip.
  it("resolveAbsoluteWorkspacePath leaves absolute paths alone", async () => {
    const resolved = await resolveAbsoluteWorkspacePath(
      "/workspace/project/custom",
    );
    expect(resolved).toBe("/workspace/project/custom");
    expect(mockGetHome).not.toHaveBeenCalled();
  });

  // @spec WUP-001 — Windows-style absolute paths are also pass-through.
  it("resolveAbsoluteWorkspacePath treats Windows drive-letter paths as absolute", async () => {
    const resolved = await resolveAbsoluteWorkspacePath("C:\\foo\\bar");
    expect(resolved).toBe("C:\\foo\\bar");
    expect(mockGetHome).not.toHaveBeenCalled();
  });

  // @spec WUP-001 — the home dir is cached so concurrent uploads share one round-trip.
  it("caches the home directory across calls", async () => {
    await resolveAbsoluteWorkspacePath("workspace/project");
    await resolveAbsoluteWorkspacePath("other/relative");
    expect(mockGetHome).toHaveBeenCalledTimes(1);
  });

  // @spec WUP-001 — failures are not cached so a later call retries fresh.
  it("does not cache failed lookups", async () => {
    mockGetHome.mockRejectedValueOnce(new Error("boom"));
    await expect(
      resolveAbsoluteWorkspacePath("workspace/project"),
    ).rejects.toThrow("boom");

    mockGetHome.mockResolvedValueOnce({ home: "/Users/test" });
    const resolved = await resolveAbsoluteWorkspacePath("workspace/project");
    expect(resolved).toBe("/Users/test/workspace/project");
    expect(mockGetHome).toHaveBeenCalledTimes(2);
  });

  // @spec WUP-001 — empty or whitespace working dirs collapse to the home dir.
  it("resolves an empty working dir to the home directory itself", async () => {
    expect(await resolveAbsoluteWorkspacePath("")).toBe("/Users/test");
    expect(await resolveAbsoluteWorkspacePath("/")).toBe("/Users/test");
  });

  // @spec WUP-001 — the safe-name helper still strips traversal segments.
  it("strips path segments from file names", () => {
    expect(getSafeUploadFileName("../../evil.txt")).toBe("evil.txt");
  });

  it("ignores trailing separators when choosing the upload file name", () => {
    expect(getSafeUploadFileName("folder/file.txt//")).toBe("file.txt");
  });

  it.each(["", ".", "..", "path/to/.."])(
    "rejects invalid upload file name %j",
    async (fileName) => {
      await expect(
        buildWorkspaceUploadPath(fileName, "/workspace/project"),
      ).rejects.toThrow("Invalid file name");
    },
  );

  // @spec WUP-001 — buildWorkspaceUploadPath uses the resolver and a safe leaf.
  it("builds an absolute upload path from a relative working dir", async () => {
    const upload = await buildWorkspaceUploadPath("a.txt", "workspace/project");
    expect(upload).toBe("/Users/test/workspace/project/a.txt");
  });

  it("rejects file names that escape the destination via path traversal", async () => {
    const upload = await buildWorkspaceUploadPath(
      "../../evil.txt",
      "/workspace/project",
    );
    expect(upload).toBe("/workspace/project/evil.txt");
  });

  it("collapses trailing slashes on the working dir", async () => {
    const upload = await buildWorkspaceUploadPath(
      "doc.md",
      "/workspace/project/",
    );
    expect(upload).toBe("/workspace/project/doc.md");
  });

  it("prefers the active conversation workspace when ids match", async () => {
    const dir = await resolveConversationUploadWorkingDir("conv-uuid", {
      id: "conv-uuid",
      workspace: { working_dir: "  /workspace/project/custom  " },
    } as never);

    expect(dir).toBe("/workspace/project/custom");
  });

  it.each([undefined, "   "])(
    "uses the configured working directory when the active workspace is %j",
    async (workingDir) => {
      const dir = await resolveConversationUploadWorkingDir(
        "named-conversation",
        {
          id: "named-conversation",
          workspace:
            workingDir === undefined ? {} : { working_dir: workingDir },
        } as never,
      );

      expect(dir).toBe(DEFAULT_WORKING_DIR);
    },
  );

  it("uses the configured working directory when the active conversation has no workspace", async () => {
    const dir = await resolveConversationUploadWorkingDir(
      "named-conversation",
      { id: "named-conversation" } as never,
    );

    expect(dir).toBe(DEFAULT_WORKING_DIR);
  });

  it("uses the stored workspace when the active conversation does not match", async () => {
    const conversationId = "stored-conversation";
    setStoredConversationMetadata(conversationId, {
      selected_repository: null,
      selected_branch: null,
      git_provider: null,
      selected_workspace: "  /workspace/project/stored  ",
    });

    try {
      const dir = await resolveConversationUploadWorkingDir(conversationId, {
        id: "different-conversation",
        workspace: { working_dir: "/workspace/project/active" },
      } as never);

      expect(dir).toBe("/workspace/project/stored");
    } finally {
      removeStoredConversationMetadata(conversationId);
    }
  });

  it.each([undefined, "   "])(
    "uses the configured working directory when the stored workspace is %j",
    async (selectedWorkspace) => {
      const conversationId = "stored-without-workspace";
      setStoredConversationMetadata(conversationId, {
        selected_repository: null,
        selected_branch: null,
        git_provider: null,
        selected_workspace: selectedWorkspace,
      });

      try {
        const dir = await resolveConversationUploadWorkingDir(conversationId);
        expect(dir).toBe(DEFAULT_WORKING_DIR);
      } finally {
        removeStoredConversationMetadata(conversationId);
      }
    },
  );

  it("resolves per-conversation dirs for UUID ids", async () => {
    const dir = await resolveConversationUploadWorkingDir(
      "550e8400-e29b-41d4-a716-446655440000",
      null,
    );

    expect(dir).toBe("/workspace/project/550e8400e29b41d4a716446655440000");
  });

  it("uses the configured working directory for non-UUID conversations", async () => {
    const dir = await resolveConversationUploadWorkingDir("named-conversation");

    expect(dir).toBe(DEFAULT_WORKING_DIR);
  });
});