diff --git a/packages/app/src/attachments/native/native-file-attachment-store.ts b/packages/app/src/attachments/native/native-file-attachment-store.ts index 2eab6236c..78c3d97cd 100644 --- a/packages/app/src/attachments/native/native-file-attachment-store.ts +++ b/packages/app/src/attachments/native/native-file-attachment-store.ts @@ -1,4 +1,5 @@ import { createLocalFileAttachmentStore } from "@/attachments/local-file-attachment-store"; +import { isAbsolutePath } from "@/utils/path"; export function createNativeFileAttachmentStore() { return createLocalFileAttachmentStore({ @@ -8,8 +9,17 @@ export function createNativeFileAttachmentStore() { if (attachment.storageKey.startsWith("file://")) { return attachment.storageKey; } - if (attachment.storageKey.startsWith("/")) { - return `file://${attachment.storageKey}`; + if (isAbsolutePath(attachment.storageKey)) { + if (attachment.storageKey.startsWith("/")) { + return `file://${attachment.storageKey}`; + } + + // UNC paths: \\server\share -> file://server/share + if (attachment.storageKey.startsWith("\\\\")) { + return `file:${attachment.storageKey.replace(/\\/g, "/")}`; + } + + return `file:///${attachment.storageKey.replace(/\\/g, "/")}`; } return attachment.storageKey; }, diff --git a/packages/app/src/attachments/utils.test.ts b/packages/app/src/attachments/utils.test.ts new file mode 100644 index 000000000..d840964aa --- /dev/null +++ b/packages/app/src/attachments/utils.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { pathToFileUri } from "./utils"; + +describe("pathToFileUri", () => { + it("converts POSIX absolute paths to file URIs", () => { + expect(pathToFileUri("/home/user/file.txt")).toBe("file:///home/user/file.txt"); + }); + + it("converts Windows drive-letter paths to file URIs", () => { + expect(pathToFileUri("C:\\Users\\file.txt")).toBe("file:///C:/Users/file.txt"); + }); + + it("converts UNC paths to host-based file URIs", () => { + expect(pathToFileUri("\\\\server\\share\\dir")).toBe("file://server/share/dir"); + }); + + it("passes through file URIs unchanged", () => { + expect(pathToFileUri("file:///already/uri")).toBe("file:///already/uri"); + }); + + it("passes through relative paths unchanged", () => { + expect(pathToFileUri("relative/path")).toBe("relative/path"); + }); +}); diff --git a/packages/app/src/attachments/utils.ts b/packages/app/src/attachments/utils.ts index 66d8266ff..1a24b8fc4 100644 --- a/packages/app/src/attachments/utils.ts +++ b/packages/app/src/attachments/utils.ts @@ -1,4 +1,5 @@ import { generateMessageId } from "@/types/stream"; +import { isAbsolutePath } from "@/utils/path"; export function generateAttachmentId(): string { return `att_${generateMessageId()}`; @@ -53,10 +54,21 @@ export function pathToFileUri(path: string): string { if (path.startsWith("file://")) { return path; } + + if (!isAbsolutePath(path)) { + return path; + } + if (path.startsWith("/")) { return `file://${path}`; } - return path; + + // UNC paths: \\server\share -> file://server/share + if (path.startsWith("\\\\")) { + return `file:${path.replace(/\\/g, "/")}`; + } + + return `file:///${path.replace(/\\/g, "/")}`; } export function fileUriToPath(uri: string): string { diff --git a/packages/app/src/hooks/image-attachment-picker.ts b/packages/app/src/hooks/image-attachment-picker.ts index c476a48a9..e11d3111c 100644 --- a/packages/app/src/hooks/image-attachment-picker.ts +++ b/packages/app/src/hooks/image-attachment-picker.ts @@ -1,4 +1,5 @@ import { getDesktopHost } from "@/desktop/host"; +import { isAbsolutePath } from "@/utils/path"; export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob }; @@ -29,12 +30,8 @@ const IMAGE_FILE_EXTENSIONS = [ "svg", ]; -function isAbsoluteWindowsPath(value: string): boolean { - return /^[a-zA-Z]:[\\/]/.test(value); -} - function shouldTreatAsFileUri(uri: string): boolean { - return uri.startsWith("file://") || uri.startsWith("/") || isAbsoluteWindowsPath(uri); + return uri.startsWith("file://") || isAbsolutePath(uri); } async function blobFromUri(uri: string): Promise { diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index ef072fdae..3e5722964 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -62,6 +62,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes"; +import { isAbsolutePath } from "@/utils/path"; import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity"; import { normalizeWorkspaceTabTarget, @@ -617,7 +618,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) enabled: Boolean(client && isConnected) && normalizedWorkspaceId.length > 0 && - normalizedWorkspaceId.startsWith("/"), + isAbsolutePath(normalizedWorkspaceId), queryFn: async () => { if (!client) { throw new Error("Host is not connected"); @@ -686,7 +687,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) const { archiveAgent } = useArchiveAgent(); useEffect(() => { - if (!client || !isConnected || !normalizedWorkspaceId.startsWith("/")) { + if (!client || !isConnected || !isAbsolutePath(normalizedWorkspaceId)) { return; } @@ -718,7 +719,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) enabled: Boolean(client && isConnected) && normalizedWorkspaceId.length > 0 && - normalizedWorkspaceId.startsWith("/"), + isAbsolutePath(normalizedWorkspaceId), queryFn: async () => { if (!client) { throw new Error("Host is not connected"); @@ -760,7 +761,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; const activeExplorerCheckout = useMemo(() => { - if (!normalizedServerId || !normalizedWorkspaceId.startsWith("/")) { + if (!normalizedServerId || !isAbsolutePath(normalizedWorkspaceId)) { return null; } return { @@ -1138,7 +1139,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) if (createTerminalMutation.isPending) { return; } - if (!normalizedWorkspaceId.startsWith("/")) { + if (!isAbsolutePath(normalizedWorkspaceId)) { return; } createTerminalMutation.mutate(input); @@ -1365,7 +1366,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) ); const handleCopyWorkspacePath = useCallback(async () => { - if (!normalizedWorkspaceId.startsWith("/")) { + if (!isAbsolutePath(normalizedWorkspaceId)) { toast.error("Workspace path not available"); return; } @@ -2056,7 +2057,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) } - disabled={!normalizedWorkspaceId.startsWith("/")} + disabled={!isAbsolutePath(normalizedWorkspaceId)} onSelect={handleCopyWorkspacePath} > Copy workspace path diff --git a/packages/app/src/utils/explorer-paths.ts b/packages/app/src/utils/explorer-paths.ts index 1dadb96c2..30f9a8f52 100644 --- a/packages/app/src/utils/explorer-paths.ts +++ b/packages/app/src/utils/explorer-paths.ts @@ -1,14 +1,10 @@ +import { isAbsolutePath } from "./path"; + interface BuildAbsoluteExplorerPathInput { workspaceRoot: string; entryPath: string; } -function isAbsolutePath(pathValue: string): boolean { - return ( - pathValue.startsWith("/") || pathValue.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(pathValue) - ); -} - export function buildAbsoluteExplorerPath({ workspaceRoot, entryPath, diff --git a/packages/app/src/utils/inline-path.ts b/packages/app/src/utils/inline-path.ts index 05d7c5d42..d30f01a2b 100644 --- a/packages/app/src/utils/inline-path.ts +++ b/packages/app/src/utils/inline-path.ts @@ -1,3 +1,5 @@ +import { isAbsolutePath } from "./path"; + export interface InlinePathTarget { raw: string; path: string; @@ -183,7 +185,7 @@ export function parseAssistantFileLink( }; } - if (!trimmed.startsWith("/")) { + if (!isAbsolutePath(trimmed)) { return null; } @@ -195,7 +197,7 @@ export function parseAssistantFileLink( } const normalizedPath = normalizePathToken(decodeURIComponent(parsedUrl.pathname)); - if (!normalizedPath || !normalizedPath.startsWith("/")) { + if (!normalizedPath || !isAbsolutePath(normalizedPath)) { return null; } @@ -334,7 +336,3 @@ function resolvePathAgainstCwd(pathValue: string, cwd?: string): string | null { function normalizePathForCompare(value: string): string { return /^[A-Za-z]:/.test(value) ? value.toLowerCase() : value; } - -function isAbsolutePath(value: string): boolean { - return value.startsWith("/") || /^[A-Za-z]:\//.test(value); -} diff --git a/packages/app/src/utils/path.test.ts b/packages/app/src/utils/path.test.ts new file mode 100644 index 000000000..b82df6e28 --- /dev/null +++ b/packages/app/src/utils/path.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { isAbsolutePath } from "./path"; + +describe("isAbsolutePath", () => { + it("returns true for Unix absolute paths", () => { + expect(isAbsolutePath("/")).toBe(true); + expect(isAbsolutePath("/home/user")).toBe(true); + expect(isAbsolutePath("/tmp/file.txt")).toBe(true); + }); + + it("returns true for Windows drive letter paths", () => { + expect(isAbsolutePath("C:\\Users")).toBe(true); + expect(isAbsolutePath("C:/Users")).toBe(true); + expect(isAbsolutePath("d:\\projects")).toBe(true); + }); + + it("returns true for UNC paths", () => { + expect(isAbsolutePath("\\\\server\\share")).toBe(true); + expect(isAbsolutePath("\\\\\\\\host\\path")).toBe(true); + }); + + it("returns false for relative paths", () => { + expect(isAbsolutePath("foo/bar")).toBe(false); + expect(isAbsolutePath("./relative")).toBe(false); + expect(isAbsolutePath("../parent")).toBe(false); + expect(isAbsolutePath("")).toBe(false); + expect(isAbsolutePath("file.txt")).toBe(false); + }); + + it("returns false for edge cases that are not absolute paths", () => { + expect(isAbsolutePath("")).toBe(false); + expect(isAbsolutePath("C:")).toBe(false); + }); + + it("handles mixed separators in absolute paths", () => { + expect(isAbsolutePath("C:/Users\\mixed/path")).toBe(true); + expect(isAbsolutePath("/tmp\\mixed/path")).toBe(true); + }); +}); diff --git a/packages/app/src/utils/path.ts b/packages/app/src/utils/path.ts new file mode 100644 index 000000000..6013fe79f --- /dev/null +++ b/packages/app/src/utils/path.ts @@ -0,0 +1,5 @@ +export function isAbsolutePath(value: string): boolean { + return ( + value.startsWith("/") || value.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(value) + ); +} diff --git a/packages/server/src/server/agent/provider-launch-config.ts b/packages/server/src/server/agent/provider-launch-config.ts index 326f404cf..267c3bd8d 100644 --- a/packages/server/src/server/agent/provider-launch-config.ts +++ b/packages/server/src/server/agent/provider-launch-config.ts @@ -1,6 +1,7 @@ import { execFileSync, execSync } from "node:child_process"; import { existsSync } from "node:fs"; import { platform } from "node:os"; +import path from "node:path"; import { shellEnvSync } from "shell-env"; import { z } from "zod"; @@ -106,7 +107,7 @@ function resolveExecutableFromWhichOutput( return null; } - if (!candidate.startsWith("/")) { + if (!path.isAbsolute(candidate)) { console.warn( `[findExecutable] Ignoring non-absolute ${source} output for '${name}': ${JSON.stringify(candidate)}`, );