From 3ed51330454b8de25909e82b16b4c1dd033169cd Mon Sep 17 00:00:00 2001 From: shrimbly Date: Fri, 30 Jan 2026 23:24:11 +1300 Subject: [PATCH 1/2] fix: normalize directory path hostname prefix from macOS network volumes On macOS, osascript's folder picker can return hostname-prefixed paths (e.g. "HOSTNAME/Users/...") for network-mounted volumes instead of absolute POSIX paths, causing directory validation to fail. Co-Authored-By: Claude Opus 4.5 --- .../browse-directory/__tests__/route.test.ts | 49 +++++++++++++++++++ src/app/api/browse-directory/route.ts | 35 ++++++++++--- src/components/ProjectSetupModal.tsx | 6 +++ .../__tests__/ProjectSetupModal.test.tsx | 35 +++++++++++++ 4 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 src/app/api/browse-directory/__tests__/route.test.ts 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..5eff72ad --- /dev/null +++ b/src/app/api/browse-directory/__tests__/route.test.ts @@ -0,0 +1,49 @@ +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", () => { + 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..a6cd2304 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..c69b0fe0 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))) { + setError("Project directory must be an absolute path (starting with / or a drive letter)"); + return; + } + setIsValidating(true); setError(null); diff --git a/src/components/__tests__/ProjectSetupModal.test.tsx b/src/components/__tests__/ProjectSetupModal.test.tsx index b9b0dffb..1e6d1173 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 / or a drive letter)") + ).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") { From 9624c44e4aa80a53f23173131ee671197d354b67 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Fri, 30 Jan 2026 23:33:47 +1300 Subject: [PATCH 2/2] fix: handle forward-slash drive roots and UNC paths in validation Broaden the trailing-slash exemption to recognize both C:\ and C:/ as drive roots. Tighten the client-side absolute path check to require a separator after the drive letter and accept UNC paths (\\server\share). Co-Authored-By: Claude Opus 4.5 --- src/app/api/browse-directory/__tests__/route.test.ts | 7 ++++++- src/app/api/browse-directory/route.ts | 2 +- src/components/ProjectSetupModal.tsx | 4 ++-- src/components/__tests__/ProjectSetupModal.test.tsx | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/app/api/browse-directory/__tests__/route.test.ts b/src/app/api/browse-directory/__tests__/route.test.ts index 5eff72ad..c325da55 100644 --- a/src/app/api/browse-directory/__tests__/route.test.ts +++ b/src/app/api/browse-directory/__tests__/route.test.ts @@ -32,11 +32,16 @@ describe("normalizeSelectedPath", () => { .toBe("C:\\Users\\guy"); }); - it("should preserve Windows drive root", () => { + 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("/"); diff --git a/src/app/api/browse-directory/route.ts b/src/app/api/browse-directory/route.ts index a6cd2304..1f3df3b2 100644 --- a/src/app/api/browse-directory/route.ts +++ b/src/app/api/browse-directory/route.ts @@ -26,7 +26,7 @@ export function normalizeSelectedPath(selectedPath: string, platform: string): s // 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))) { + if (!(platform === "win32" && /^[A-Za-z]:[\\\/]$/.test(selectedPath))) { selectedPath = selectedPath.slice(0, -1); } } diff --git a/src/components/ProjectSetupModal.tsx b/src/components/ProjectSetupModal.tsx index c69b0fe0..7476907a 100644 --- a/src/components/ProjectSetupModal.tsx +++ b/src/components/ProjectSetupModal.tsx @@ -200,8 +200,8 @@ export function ProjectSetupModal({ } const trimmedPath = directoryPath.trim(); - if (!(trimmedPath.startsWith("/") || /^[A-Za-z]:/.test(trimmedPath))) { - setError("Project directory must be an absolute path (starting with / or a drive letter)"); + 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; } diff --git a/src/components/__tests__/ProjectSetupModal.test.tsx b/src/components/__tests__/ProjectSetupModal.test.tsx index 1e6d1173..ed1a0efb 100644 --- a/src/components/__tests__/ProjectSetupModal.test.tsx +++ b/src/components/__tests__/ProjectSetupModal.test.tsx @@ -407,7 +407,7 @@ describe("ProjectSetupModal", () => { await waitFor(() => { expect( - screen.getByText("Project directory must be an absolute path (starting with / or a drive letter)") + screen.getByText("Project directory must be an absolute path (starting with /, a drive letter, or a UNC path)") ).toBeInTheDocument(); }); expect(onSave).not.toHaveBeenCalled();