diff --git a/src/app/api/browse-directory/__tests__/route.test.ts b/src/app/api/browse-directory/__tests__/route.test.ts new file mode 100644 index 00000000..c325da55 --- /dev/null +++ b/src/app/api/browse-directory/__tests__/route.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { normalizeSelectedPath } from "../route"; + +describe("normalizeSelectedPath", () => { + it("should strip hostname prefix on macOS", () => { + expect(normalizeSelectedPath("AT-ALGKG9VR/Users/guy/Desktop", "darwin")) + .toBe("/Users/guy/Desktop"); + }); + + it("should preserve absolute paths on macOS", () => { + expect(normalizeSelectedPath("/Users/guy/Desktop", "darwin")) + .toBe("/Users/guy/Desktop"); + }); + + it("should remove trailing slash", () => { + expect(normalizeSelectedPath("/Users/guy/Desktop/", "darwin")) + .toBe("/Users/guy/Desktop"); + }); + + it("should strip hostname and trailing slash", () => { + expect(normalizeSelectedPath("HOST/Users/guy/", "darwin")) + .toBe("/Users/guy"); + }); + + it("should strip hostname prefix on Linux", () => { + expect(normalizeSelectedPath("hostname/home/user", "linux")) + .toBe("/home/user"); + }); + + it("should not modify Windows drive paths", () => { + expect(normalizeSelectedPath("C:\\Users\\guy", "win32")) + .toBe("C:\\Users\\guy"); + }); + + it("should preserve Windows drive root with backslash", () => { + expect(normalizeSelectedPath("C:\\", "win32")) + .toBe("C:\\"); + }); + + it("should preserve Windows drive root with forward slash", () => { + expect(normalizeSelectedPath("C:/", "win32")) + .toBe("C:/"); + }); + + it("should preserve Unix root /", () => { + expect(normalizeSelectedPath("/", "darwin")) + .toBe("/"); + }); + + it("should leave hostname-only (no slash) as-is", () => { + expect(normalizeSelectedPath("HOSTNAME", "darwin")) + .toBe("HOSTNAME"); + }); +}); diff --git a/src/app/api/browse-directory/route.ts b/src/app/api/browse-directory/route.ts index 607f2ac2..1f3df3b2 100644 --- a/src/app/api/browse-directory/route.ts +++ b/src/app/api/browse-directory/route.ts @@ -7,6 +7,33 @@ import { join } from "path"; const execAsync = promisify(exec); +/** + * Normalize a path returned by native directory pickers. + * On macOS, osascript can return hostname-prefixed paths for network volumes + * (e.g. "HOSTNAME/Users/..." instead of "/Users/..."). This strips the + * hostname prefix and cleans up trailing slashes. + */ +export function normalizeSelectedPath(selectedPath: string, platform: string): string { + // On macOS/Linux, ensure the path is absolute. + // osascript can return hostname-prefixed paths for network volumes + // e.g. "AT-ALGKG9VR/Users/guy/Desktop" instead of "/Users/guy/Desktop" + if ((platform === "darwin" || platform === "linux") && !selectedPath.startsWith("/")) { + const firstSlash = selectedPath.indexOf("/"); + if (firstSlash >= 0) { + selectedPath = selectedPath.substring(firstSlash); + } + } + + // Remove trailing slash/backslash (except root paths like "/" or "C:\") + if (selectedPath.length > 1 && (selectedPath.endsWith("/") || selectedPath.endsWith("\\"))) { + if (!(platform === "win32" && /^[A-Za-z]:[\\\/]$/.test(selectedPath))) { + selectedPath = selectedPath.slice(0, -1); + } + } + + return selectedPath; +} + // GET: Open native directory picker and return the selected path export async function GET() { const platform = process.platform; @@ -121,13 +148,7 @@ if ($result) { Write-Output $result } }); } - // Remove trailing slash/backslash if present (except for root paths like "/" or "C:\") - if (selectedPath.length > 1 && (selectedPath.endsWith("/") || selectedPath.endsWith("\\"))) { - // Don't remove trailing slash from Windows drive roots like "C:\" - if (!(platform === "win32" && /^[A-Za-z]:\\$/.test(selectedPath))) { - selectedPath = selectedPath.slice(0, -1); - } - } + selectedPath = normalizeSelectedPath(selectedPath, platform); return NextResponse.json({ success: true, diff --git a/src/components/ProjectSetupModal.tsx b/src/components/ProjectSetupModal.tsx index 0812ccfa..7476907a 100644 --- a/src/components/ProjectSetupModal.tsx +++ b/src/components/ProjectSetupModal.tsx @@ -199,6 +199,12 @@ export function ProjectSetupModal({ return; } + const trimmedPath = directoryPath.trim(); + if (!(trimmedPath.startsWith("/") || /^[A-Za-z]:[\\\/]/.test(trimmedPath) || trimmedPath.startsWith("\\\\"))) { + setError("Project directory must be an absolute path (starting with /, a drive letter, or a UNC path)"); + return; + } + setIsValidating(true); setError(null); diff --git a/src/components/__tests__/ProjectSetupModal.test.tsx b/src/components/__tests__/ProjectSetupModal.test.tsx index b9b0dffb..ed1a0efb 100644 --- a/src/components/__tests__/ProjectSetupModal.test.tsx +++ b/src/components/__tests__/ProjectSetupModal.test.tsx @@ -382,6 +382,41 @@ describe("ProjectSetupModal", () => { expect(onSave).not.toHaveBeenCalled(); }); + it("should show error when path is not absolute", async () => { + const onSave = vi.fn(); + + render( + + ); + + // Fill name and a hostname-prefixed relative path + fireEvent.change(screen.getByPlaceholderText("my-project"), { + target: { value: "My Project" }, + }); + fireEvent.change(screen.getByPlaceholderText("/Users/username/projects/my-project"), { + target: { value: "AT-ALGKG9VR/Users/guy/Desktop/AI Project" }, + }); + + // Click Create + fireEvent.click(screen.getByText("Create")); + + await waitFor(() => { + expect( + screen.getByText("Project directory must be an absolute path (starting with /, a drive letter, or a UNC path)") + ).toBeInTheDocument(); + }); + expect(onSave).not.toHaveBeenCalled(); + // Validation should fail client-side without making a fetch to /api/workflow + expect(mockFetch).not.toHaveBeenCalledWith( + expect.stringContaining("/api/workflow") + ); + }); + it("should show error when path is not a directory", async () => { mockFetch.mockImplementation((url: string) => { if (url === "/api/env-status") {