fix: replace Unix path assumptions with cross-platform isAbsolutePath
Windows daemon paths like C:\Users\... don't start with "/", breaking the explorer sidebar, checkout status, terminals, and file attachments when connected to a Windows daemon. Consolidate three private isAbsolutePath implementations into a shared utility and use it everywhere, with correct file URI conversion for Windows and UNC paths.
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { createLocalFileAttachmentStore } from "@/attachments/local-file-attachment-store";
|
import { createLocalFileAttachmentStore } from "@/attachments/local-file-attachment-store";
|
||||||
|
import { isAbsolutePath } from "@/utils/path";
|
||||||
|
|
||||||
export function createNativeFileAttachmentStore() {
|
export function createNativeFileAttachmentStore() {
|
||||||
return createLocalFileAttachmentStore({
|
return createLocalFileAttachmentStore({
|
||||||
@@ -8,8 +9,17 @@ export function createNativeFileAttachmentStore() {
|
|||||||
if (attachment.storageKey.startsWith("file://")) {
|
if (attachment.storageKey.startsWith("file://")) {
|
||||||
return attachment.storageKey;
|
return attachment.storageKey;
|
||||||
}
|
}
|
||||||
if (attachment.storageKey.startsWith("/")) {
|
if (isAbsolutePath(attachment.storageKey)) {
|
||||||
return `file://${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;
|
return attachment.storageKey;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { generateMessageId } from "@/types/stream";
|
import { generateMessageId } from "@/types/stream";
|
||||||
|
import { isAbsolutePath } from "@/utils/path";
|
||||||
|
|
||||||
export function generateAttachmentId(): string {
|
export function generateAttachmentId(): string {
|
||||||
return `att_${generateMessageId()}`;
|
return `att_${generateMessageId()}`;
|
||||||
@@ -53,10 +54,21 @@ export function pathToFileUri(path: string): string {
|
|||||||
if (path.startsWith("file://")) {
|
if (path.startsWith("file://")) {
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isAbsolutePath(path)) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
if (path.startsWith("/")) {
|
if (path.startsWith("/")) {
|
||||||
return `file://${path}`;
|
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 {
|
export function fileUriToPath(uri: string): string {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getDesktopHost } from "@/desktop/host";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
|
import { isAbsolutePath } from "@/utils/path";
|
||||||
|
|
||||||
export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob };
|
export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob };
|
||||||
|
|
||||||
@@ -29,12 +30,8 @@ const IMAGE_FILE_EXTENSIONS = [
|
|||||||
"svg",
|
"svg",
|
||||||
];
|
];
|
||||||
|
|
||||||
function isAbsoluteWindowsPath(value: string): boolean {
|
|
||||||
return /^[a-zA-Z]:[\\/]/.test(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldTreatAsFileUri(uri: string): boolean {
|
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<Blob> {
|
async function blobFromUri(uri: string): Promise<Blob> {
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
|||||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||||
|
import { isAbsolutePath } from "@/utils/path";
|
||||||
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
|
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
|
||||||
import {
|
import {
|
||||||
normalizeWorkspaceTabTarget,
|
normalizeWorkspaceTabTarget,
|
||||||
@@ -617,7 +618,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
enabled:
|
enabled:
|
||||||
Boolean(client && isConnected) &&
|
Boolean(client && isConnected) &&
|
||||||
normalizedWorkspaceId.length > 0 &&
|
normalizedWorkspaceId.length > 0 &&
|
||||||
normalizedWorkspaceId.startsWith("/"),
|
isAbsolutePath(normalizedWorkspaceId),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
@@ -686,7 +687,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
const { archiveAgent } = useArchiveAgent();
|
const { archiveAgent } = useArchiveAgent();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!client || !isConnected || !normalizedWorkspaceId.startsWith("/")) {
|
if (!client || !isConnected || !isAbsolutePath(normalizedWorkspaceId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -718,7 +719,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
enabled:
|
enabled:
|
||||||
Boolean(client && isConnected) &&
|
Boolean(client && isConnected) &&
|
||||||
normalizedWorkspaceId.length > 0 &&
|
normalizedWorkspaceId.length > 0 &&
|
||||||
normalizedWorkspaceId.startsWith("/"),
|
isAbsolutePath(normalizedWorkspaceId),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
@@ -760,7 +761,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||||
|
|
||||||
const activeExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => {
|
const activeExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => {
|
||||||
if (!normalizedServerId || !normalizedWorkspaceId.startsWith("/")) {
|
if (!normalizedServerId || !isAbsolutePath(normalizedWorkspaceId)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -1138,7 +1139,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
if (createTerminalMutation.isPending) {
|
if (createTerminalMutation.isPending) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!normalizedWorkspaceId.startsWith("/")) {
|
if (!isAbsolutePath(normalizedWorkspaceId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
createTerminalMutation.mutate(input);
|
createTerminalMutation.mutate(input);
|
||||||
@@ -1365,7 +1366,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleCopyWorkspacePath = useCallback(async () => {
|
const handleCopyWorkspacePath = useCallback(async () => {
|
||||||
if (!normalizedWorkspaceId.startsWith("/")) {
|
if (!isAbsolutePath(normalizedWorkspaceId)) {
|
||||||
toast.error("Workspace path not available");
|
toast.error("Workspace path not available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2056,7 +2057,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
testID="workspace-header-copy-path"
|
testID="workspace-header-copy-path"
|
||||||
leading={<Copy size={16} color={theme.colors.foregroundMuted} />}
|
leading={<Copy size={16} color={theme.colors.foregroundMuted} />}
|
||||||
disabled={!normalizedWorkspaceId.startsWith("/")}
|
disabled={!isAbsolutePath(normalizedWorkspaceId)}
|
||||||
onSelect={handleCopyWorkspacePath}
|
onSelect={handleCopyWorkspacePath}
|
||||||
>
|
>
|
||||||
Copy workspace path
|
Copy workspace path
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
|
import { isAbsolutePath } from "./path";
|
||||||
|
|
||||||
interface BuildAbsoluteExplorerPathInput {
|
interface BuildAbsoluteExplorerPathInput {
|
||||||
workspaceRoot: string;
|
workspaceRoot: string;
|
||||||
entryPath: string;
|
entryPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAbsolutePath(pathValue: string): boolean {
|
|
||||||
return (
|
|
||||||
pathValue.startsWith("/") || pathValue.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(pathValue)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildAbsoluteExplorerPath({
|
export function buildAbsoluteExplorerPath({
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
entryPath,
|
entryPath,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { isAbsolutePath } from "./path";
|
||||||
|
|
||||||
export interface InlinePathTarget {
|
export interface InlinePathTarget {
|
||||||
raw: string;
|
raw: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -183,7 +185,7 @@ export function parseAssistantFileLink(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!trimmed.startsWith("/")) {
|
if (!isAbsolutePath(trimmed)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +197,7 @@ export function parseAssistantFileLink(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalizedPath = normalizePathToken(decodeURIComponent(parsedUrl.pathname));
|
const normalizedPath = normalizePathToken(decodeURIComponent(parsedUrl.pathname));
|
||||||
if (!normalizedPath || !normalizedPath.startsWith("/")) {
|
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,7 +336,3 @@ function resolvePathAgainstCwd(pathValue: string, cwd?: string): string | null {
|
|||||||
function normalizePathForCompare(value: string): string {
|
function normalizePathForCompare(value: string): string {
|
||||||
return /^[A-Za-z]:/.test(value) ? value.toLowerCase() : value;
|
return /^[A-Za-z]:/.test(value) ? value.toLowerCase() : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAbsolutePath(value: string): boolean {
|
|
||||||
return value.startsWith("/") || /^[A-Za-z]:\//.test(value);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export function isAbsolutePath(value: string): boolean {
|
||||||
|
return (
|
||||||
|
value.startsWith("/") || value.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { execFileSync, execSync } from "node:child_process";
|
import { execFileSync, execSync } from "node:child_process";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { platform } from "node:os";
|
import { platform } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
import { shellEnvSync } from "shell-env";
|
import { shellEnvSync } from "shell-env";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -106,7 +107,7 @@ function resolveExecutableFromWhichOutput(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!candidate.startsWith("/")) {
|
if (!path.isAbsolute(candidate)) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[findExecutable] Ignoring non-absolute ${source} output for '${name}': ${JSON.stringify(candidate)}`,
|
`[findExecutable] Ignoring non-absolute ${source} output for '${name}': ${JSON.stringify(candidate)}`,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user