From 3585c802661f52450a076903a19734685d1fe8f3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 29 May 2026 11:35:38 +0800 Subject: [PATCH] Allow previews to open readable files (#1214) * Allow previews to open readable files * Fix Windows file preview path test * Address file preview review comments --- SECURITY.md | 2 + .../src/assistant-file-links/parse.test.ts | 51 ++++++++- .../app/src/assistant-file-links/parse.ts | 39 ++++--- .../src/assistant-file-links/resolver.test.ts | 73 ++++++++---- packages/app/src/components/file-pane.tsx | 19 +++- .../src/file-explorer/preview-target.test.ts | 74 ++++++++++++ .../app/src/file-explorer/preview-target.ts | 107 ++++++++++++++++++ .../src/utils/assistant-image-source.test.ts | 13 +++ .../app/src/utils/assistant-image-source.ts | 86 ++------------ .../src/server/file-explorer/service.test.ts | 21 ++++ .../src/server/file-explorer/service.ts | 8 +- 11 files changed, 361 insertions(+), 132 deletions(-) create mode 100644 packages/app/src/file-explorer/preview-target.test.ts create mode 100644 packages/app/src/file-explorer/preview-target.ts diff --git a/SECURITY.md b/SECURITY.md index d932be7a3..4a47a6e36 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -46,6 +46,8 @@ By default, the daemon binds to `127.0.0.1`. With no password configured, the lo The daemon also supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). When configured, every HTTP request must carry `Authorization: Bearer ` and every WebSocket upgrade must include a `Sec-WebSocket-Protocol: paseo.bearer.` subprotocol. Browser WebSocket cannot set custom headers, which is why the token rides in the subprotocol. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt. The password is intended for direct-TCP exposure (e.g. `tcp://host:port?ssl=true&password=...`); it is **not** a substitute for the relay's E2E encryption when traversing untrusted networks. +Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary. + If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case. For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted. diff --git a/packages/app/src/assistant-file-links/parse.test.ts b/packages/app/src/assistant-file-links/parse.test.ts index d3dc823cb..12921b855 100644 --- a/packages/app/src/assistant-file-links/parse.test.ts +++ b/packages/app/src/assistant-file-links/parse.test.ts @@ -336,12 +336,50 @@ describe("parseAssistantFileLink", () => { }); }); - it("rejects absolute hrefs outside the workspace root", () => { + it("allows absolute hrefs outside the workspace root", () => { expect( parseAssistantFileLink("/tmp/outside.txt", { workspaceRoot: "/Users/test/project", }), - ).toBeNull(); + ).toEqual({ + raw: "/tmp/outside.txt", + path: "/tmp/outside.txt", + lineStart: undefined, + lineEnd: undefined, + }); + }); + + it("keeps tilde hrefs as direct home-relative file targets", () => { + expect( + parseAssistantFileLink("~/.paseo/plans/file-preview.md", { + workspaceRoot: "/Users/test/project", + }), + ).toEqual({ + raw: "~/.paseo/plans/file-preview.md", + path: "~/.paseo/plans/file-preview.md", + lineStart: undefined, + lineEnd: undefined, + }); + expect( + parseAssistantFileLink("~/.paseo/plans/file-preview.md:12", { + workspaceRoot: "/Users/test/project", + }), + ).toEqual({ + raw: "~/.paseo/plans/file-preview.md:12", + path: "~/.paseo/plans/file-preview.md", + lineStart: 12, + lineEnd: undefined, + }); + expect( + parseAssistantFileLink("~\\.paseo\\plans\\file-preview.md", { + workspaceRoot: "/Users/test/project", + }), + ).toEqual({ + raw: "~\\.paseo\\plans\\file-preview.md", + path: "~/.paseo/plans/file-preview.md", + lineStart: undefined, + lineEnd: undefined, + }); }); it("rejects external URLs", () => { @@ -408,6 +446,15 @@ describe("normalizeInlinePathTarget", () => { }); }); + it("keeps tilde paths as home-relative file targets", () => { + expect( + normalizeInlinePathTarget("~/.paseo/plans/file-preview.md", "/Users/test/project"), + ).toEqual({ + directory: "~/.paseo/plans", + file: "~/.paseo/plans/file-preview.md", + }); + }); + it("treats cwd itself as the workspace root directory", () => { expect(normalizeInlinePathTarget("/Users/test/project", "/Users/test/project")).toEqual({ directory: ".", diff --git a/packages/app/src/assistant-file-links/parse.ts b/packages/app/src/assistant-file-links/parse.ts index bfcf31842..bd0015f0b 100644 --- a/packages/app/src/assistant-file-links/parse.ts +++ b/packages/app/src/assistant-file-links/parse.ts @@ -233,10 +233,7 @@ export function parseFileProtocolUrl(value: string): InlinePathTarget | null { }; } -function parseAssistantInlinePathLink( - value: string, - options: AssistantHrefParseOptions, -): InlinePathTarget | null { +function parseAssistantInlinePathLink(value: string): InlinePathTarget | null { const inlinePathTarget = parseInlinePathToken(value); if (!inlinePathTarget) { return null; @@ -247,10 +244,6 @@ function parseAssistantInlinePathLink( return null; } - if (!isAllowedAbsolutePath(normalizedPath, options.workspaceRoot)) { - return null; - } - return { ...inlinePathTarget, path: normalizedPath, @@ -314,9 +307,7 @@ export function parseAssistantFileLink( return null; } - const inlinePathTarget = parseAssistantInlinePathLink(trimmed, { - workspaceRoot: options.workspaceRoot, - }); + const inlinePathTarget = parseAssistantInlinePathLink(trimmed); if (inlinePathTarget) { return inlinePathTarget; } @@ -324,7 +315,7 @@ export function parseAssistantFileLink( const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/); if (windowsPathMatch) { const normalizedPath = normalizePathToken(windowsPathMatch[1] ?? ""); - if (!normalizedPath || !isAllowedAbsolutePath(normalizedPath, options.workspaceRoot)) { + if (!normalizedPath) { return null; } @@ -363,10 +354,6 @@ export function parseAssistantFileLink( return null; } - if (!isAllowedAbsolutePath(normalizedPath, options.workspaceRoot)) { - return null; - } - const lines = parseLineFragment(parsedUrl.hash); if (!lines) { return null; @@ -402,13 +389,21 @@ function parseWorkspaceRelativeFileLink( value: string, options: AssistantHrefParseOptions, ): InlinePathTarget | null { - const workspaceRoot = normalizePathInput(options.workspaceRoot); - if (!workspaceRoot) { + const parsed = parseLocalPathParts(value); + if (!parsed || isAbsolutePath(parsed.path)) { return null; } - const parsed = parseLocalPathParts(value); - if (!parsed || isAbsolutePath(parsed.path)) { + if (isHomeRelativePath(parsed.path)) { + return { + raw: value, + path: parsed.path, + ...parsed.lines, + }; + } + + const workspaceRoot = normalizePathInput(options.workspaceRoot); + if (!workspaceRoot) { return null; } @@ -529,6 +524,10 @@ function isAllowedAbsolutePath(pathValue: string, workspaceRoot?: string): boole return comparePath === compareWorkspaceRoot || comparePath.startsWith(comparePrefix); } +function isHomeRelativePath(pathValue: string): boolean { + return pathValue === "~" || pathValue.startsWith("~/") || pathValue.startsWith("~\\"); +} + function isExternalHref(value: string): boolean { if (value.includes("://")) { return !value.toLowerCase().startsWith(`${FILE_PROTOCOL}//`); diff --git a/packages/app/src/assistant-file-links/resolver.test.ts b/packages/app/src/assistant-file-links/resolver.test.ts index 5539f20f7..312bfff1f 100644 --- a/packages/app/src/assistant-file-links/resolver.test.ts +++ b/packages/app/src/assistant-file-links/resolver.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { classifyForResolution, fetchDaemonResolution, @@ -35,27 +35,22 @@ function suggestionsFromMap(entriesByQuery: Record = []; - const getDirectorySuggestions = vi.fn( - async (input: { - query: string; - cwd: string; - includeFiles: true; - includeDirectories: false; - matchMode: "suffix"; - limit: number; - }) => { - searches.push({ - query: input.query, - cwd: input.cwd, - matchMode: input.matchMode, - limit: input.limit, - }); - return resolvedSuggestions(entriesByQuery[input.query] ?? []); - }, - ); + const getDirectorySuggestions: GetDirectorySuggestions = async (input) => { + searches.push({ + query: input.query, + cwd: input.cwd, + matchMode: input.matchMode, + limit: input.limit, + }); + return resolvedSuggestions(entriesByQuery[input.query] ?? []); + }; return { getDirectorySuggestions, searches }; } +const unavailableSuggestions: GetDirectorySuggestions = async () => { + throw new Error("daemon unavailable"); +}; + describe("classifyForResolution", () => { it("returns the directFile target synchronously", () => { const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT); @@ -119,6 +114,40 @@ describe("classifyForResolution", () => { }); }); + it("keeps absolute paths outside the workspace as direct file targets", () => { + const result = classifyForResolution({ href: "/tmp/outside.txt" }, CONTEXT); + + expect(result).toEqual({ + kind: "resolved", + value: { + kind: "file", + target: { + raw: "/tmp/outside.txt", + path: "/tmp/outside.txt", + lineStart: undefined, + lineEnd: undefined, + }, + }, + }); + }); + + it("keeps tilde paths as direct file targets", () => { + const result = classifyForResolution({ href: "~/.paseo/plans/file-preview.md" }, CONTEXT); + + expect(result).toEqual({ + kind: "resolved", + value: { + kind: "file", + target: { + raw: "~/.paseo/plans/file-preview.md", + path: "~/.paseo/plans/file-preview.md", + lineStart: undefined, + lineEnd: undefined, + }, + }, + }); + }); + it("keeps auto-linkified normal domains external", () => { const result = classifyForResolution( { href: "http://google.com", text: "google.com", markup: "linkify" }, @@ -193,10 +222,6 @@ describe("fetchDaemonResolution", () => { }); it("throws a typed unresolved error when the daemon throws", async () => { - const getDirectorySuggestions = vi.fn(async () => { - throw new Error("daemon unavailable"); - }); - await expect( fetchDaemonResolution({ ambiguousQuery: "dumm.md", @@ -208,7 +233,7 @@ describe("fetchDaemonResolution", () => { lineEnd: undefined, }, workspaceRoot: "/Users/test/project", - getDirectorySuggestions, + getDirectorySuggestions: unavailableSuggestions, }), ).rejects.toEqual(new UnresolvedFileLinkError("dumm.md")); }); diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index 0d3d69f2e..3f6e5e104 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -27,6 +27,7 @@ import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-ur import { persistAttachmentFromBytes } from "@/attachments/service"; import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils"; import { explorerFileFromReadResult } from "@/file-explorer/read-result"; +import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target"; import type { WorkspaceFileLocation } from "@/workspace/file-open"; interface CodeLineProps { @@ -398,16 +399,26 @@ export function FilePane({ const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]); const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]); + const readTarget = useMemo( + () => + normalizedFilePath + ? resolveFilePreviewReadTarget({ + path: normalizedFilePath, + workspaceRoot: normalizedWorkspaceRoot, + }) + : null, + [normalizedFilePath, normalizedWorkspaceRoot], + ); const query = useQuery({ - queryKey: ["workspaceFile", serverId, normalizedWorkspaceRoot, normalizedFilePath], - enabled: Boolean(client && normalizedWorkspaceRoot && normalizedFilePath), + queryKey: ["workspaceFile", serverId, readTarget?.cwd ?? null, readTarget?.path ?? null], + enabled: Boolean(client && readTarget), queryFn: async () => { - if (!client || !normalizedWorkspaceRoot || !normalizedFilePath) { + if (!client || !readTarget) { return { file: null as ExplorerFile | null, error: "Host is not connected" }; } try { - const file = await client.readFile(normalizedWorkspaceRoot, normalizedFilePath); + const file = await client.readFile(readTarget.cwd, readTarget.path); const preview = await createFilePanePreview(file); return { file: preview.file, diff --git a/packages/app/src/file-explorer/preview-target.test.ts b/packages/app/src/file-explorer/preview-target.test.ts new file mode 100644 index 000000000..712cfc701 --- /dev/null +++ b/packages/app/src/file-explorer/preview-target.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { resolveFilePreviewReadTarget } from "./preview-target"; + +describe("resolveFilePreviewReadTarget", () => { + it("uses the workspace cwd for relative paths", () => { + expect( + resolveFilePreviewReadTarget({ + path: "packages/app/src/message.tsx", + workspaceRoot: "/Users/test/project", + }), + ).toEqual({ + cwd: "/Users/test/project", + path: "packages/app/src/message.tsx", + }); + }); + + it("uses the workspace cwd for absolute paths inside the workspace", () => { + expect( + resolveFilePreviewReadTarget({ + path: "/Users/test/project/packages/app/src/message.tsx", + workspaceRoot: "/Users/test/project", + }), + ).toEqual({ + cwd: "/Users/test/project", + path: "/Users/test/project/packages/app/src/message.tsx", + }); + }); + + it("uses the filesystem root for absolute paths outside the workspace", () => { + expect( + resolveFilePreviewReadTarget({ + path: "/tmp/paseo-preview.txt", + workspaceRoot: "/Users/test/project", + }), + ).toEqual({ + cwd: "/", + path: "/tmp/paseo-preview.txt", + }); + }); + + it("uses the home root for tilde paths", () => { + expect( + resolveFilePreviewReadTarget({ + path: "~/.paseo/plans/file-preview.md", + workspaceRoot: "/Users/test/project", + }), + ).toEqual({ + cwd: "~", + path: "~/.paseo/plans/file-preview.md", + }); + }); + + it("uses the drive root for Windows absolute paths outside the workspace", () => { + expect( + resolveFilePreviewReadTarget({ + path: "C:/Users/test/Desktop/file.txt", + workspaceRoot: "D:/repo", + }), + ).toEqual({ + cwd: "C:/", + path: "C:/Users/test/Desktop/file.txt", + }); + }); + + it("rejects relative paths without an absolute workspace root", () => { + expect(resolveFilePreviewReadTarget({ path: "src/app.ts" })).toBeNull(); + expect( + resolveFilePreviewReadTarget({ + path: "src/app.ts", + workspaceRoot: "relative/root", + }), + ).toBeNull(); + }); +}); diff --git a/packages/app/src/file-explorer/preview-target.ts b/packages/app/src/file-explorer/preview-target.ts new file mode 100644 index 000000000..c47bde70e --- /dev/null +++ b/packages/app/src/file-explorer/preview-target.ts @@ -0,0 +1,107 @@ +import { isAbsolutePath } from "@/utils/path"; + +export interface FilePreviewReadTarget { + cwd: string; + path: string; +} + +function trimTrailingSeparators(value: string): string { + if (value === "/" || /^[A-Za-z]:[\\/]?$/.test(value)) { + return value.replace(/\\/g, "/"); + } + return value.replace(/[\\/]+$/, ""); +} + +function normalizeForPathComparison(value: string): string { + const normalized = trimTrailingSeparators(value.replace(/\\/g, "/")); + if (/^[A-Za-z]:\//.test(normalized)) { + return `${normalized.slice(0, 1).toUpperCase()}${normalized.slice(1)}`; + } + return normalized; +} + +function isPathWithinRoot(candidatePath: string, rootPath: string): boolean { + const candidate = normalizeForPathComparison(candidatePath); + const root = normalizeForPathComparison(rootPath); + if (!candidate || !root) { + return false; + } + if (root === "/") { + return candidate.startsWith("/"); + } + if (candidate === root) { + return true; + } + return candidate.startsWith(`${root}/`); +} + +function deriveFilesystemRootFromAbsolutePath(value: string): string | null { + if (value.startsWith("/")) { + return "/"; + } + + const driveMatch = /^([A-Za-z]:)[\\/]/.exec(value); + if (driveMatch?.[1]) { + return `${driveMatch[1]}/`; + } + + const uncMatch = /^(\\\\[^\\]+\\[^\\]+)/.exec(value); + if (uncMatch?.[1]) { + return uncMatch[1]; + } + + return null; +} + +function isHomeRelativePath(value: string): boolean { + return value === "~" || value.startsWith("~/") || value.startsWith("~\\"); +} + +export function resolveFilePreviewReadTarget(input: { + path: string; + workspaceRoot?: string; +}): FilePreviewReadTarget | null { + const previewPath = input.path.trim(); + if (!previewPath) { + return null; + } + + if (isHomeRelativePath(previewPath)) { + return { + cwd: "~", + path: previewPath, + }; + } + + const workspaceRoot = input.workspaceRoot?.trim(); + if (!isAbsolutePath(previewPath)) { + if (!workspaceRoot || !isAbsolutePath(workspaceRoot)) { + return null; + } + return { + cwd: workspaceRoot, + path: previewPath, + }; + } + + if ( + workspaceRoot && + isAbsolutePath(workspaceRoot) && + isPathWithinRoot(previewPath, workspaceRoot) + ) { + return { + cwd: workspaceRoot, + path: previewPath, + }; + } + + const filesystemRoot = deriveFilesystemRootFromAbsolutePath(previewPath); + if (!filesystemRoot) { + return null; + } + + return { + cwd: filesystemRoot, + path: previewPath, + }; +} diff --git a/packages/app/src/utils/assistant-image-source.test.ts b/packages/app/src/utils/assistant-image-source.test.ts index 755d27cfc..2c2183ec8 100644 --- a/packages/app/src/utils/assistant-image-source.test.ts +++ b/packages/app/src/utils/assistant-image-source.test.ts @@ -53,6 +53,19 @@ describe("resolveAssistantImageSource", () => { }); }); + it("uses the same home-root target as file previews for tilde paths", () => { + expect( + resolveAssistantImageSource({ + source: "~/.paseo/screenshots/output.png", + workspaceRoot: "/Users/test/project", + }), + ).toEqual({ + kind: "file_rpc", + cwd: "~", + path: "~/.paseo/screenshots/output.png", + }); + }); + it("normalizes file URIs into file RPC requests", () => { expect( resolveAssistantImageSource({ diff --git a/packages/app/src/utils/assistant-image-source.ts b/packages/app/src/utils/assistant-image-source.ts index fda471483..d3cd01b73 100644 --- a/packages/app/src/utils/assistant-image-source.ts +++ b/packages/app/src/utils/assistant-image-source.ts @@ -1,58 +1,10 @@ import { fileUriToPath } from "@/attachments/utils"; -import { isAbsolutePath } from "@/utils/path"; +import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target"; export type AssistantImageSourceResolution = | { kind: "direct"; uri: string } | { kind: "file_rpc"; cwd: string; path: string }; -function trimTrailingSeparators(value: string): string { - if (value === "/" || /^[A-Za-z]:[\\/]?$/.test(value)) { - return value.replace(/\\/g, "/"); - } - return value.replace(/[\\/]+$/, ""); -} - -function normalizeForPathComparison(value: string): string { - const normalized = trimTrailingSeparators(value.replace(/\\/g, "/")); - if (/^[A-Za-z]:\//.test(normalized)) { - return `${normalized.slice(0, 1).toUpperCase()}${normalized.slice(1)}`; - } - return normalized; -} - -function isPathWithinRoot(candidatePath: string, rootPath: string): boolean { - const candidate = normalizeForPathComparison(candidatePath); - const root = normalizeForPathComparison(rootPath); - if (!candidate || !root) { - return false; - } - if (root === "/") { - return candidate.startsWith("/"); - } - if (candidate === root) { - return true; - } - return candidate.startsWith(`${root}/`); -} - -function deriveFallbackRootFromAbsolutePath(value: string): string | null { - if (value.startsWith("/")) { - return "/"; - } - - const driveMatch = /^([A-Za-z]:)[\\/]/.exec(value); - if (driveMatch?.[1]) { - return `${driveMatch[1]}/`; - } - - const uncMatch = /^(\\\\[^\\]+\\[^\\]+)/.exec(value); - if (uncMatch?.[1]) { - return uncMatch[1]; - } - - return null; -} - export function resolveAssistantImageSource(input: { source: string; workspaceRoot?: string; @@ -71,39 +23,17 @@ export function resolveAssistantImageSource(input: { return null; } - if (!isAbsolutePath(sourcePath)) { - const workspaceRoot = input.workspaceRoot?.trim(); - if (!workspaceRoot || !isAbsolutePath(workspaceRoot)) { - return null; - } - return { - kind: "file_rpc", - cwd: workspaceRoot, - path: sourcePath, - }; - } - - const workspaceRoot = input.workspaceRoot?.trim(); - if ( - workspaceRoot && - isAbsolutePath(workspaceRoot) && - isPathWithinRoot(sourcePath, workspaceRoot) - ) { - return { - kind: "file_rpc", - cwd: workspaceRoot, - path: sourcePath, - }; - } - - const fallbackRoot = deriveFallbackRootFromAbsolutePath(sourcePath); - if (!fallbackRoot) { + const readTarget = resolveFilePreviewReadTarget({ + path: sourcePath, + workspaceRoot: input.workspaceRoot, + }); + if (!readTarget) { return null; } return { kind: "file_rpc", - cwd: fallbackRoot, - path: sourcePath, + cwd: readTarget.cwd, + path: readTarget.path, }; } diff --git a/packages/server/src/server/file-explorer/service.test.ts b/packages/server/src/server/file-explorer/service.test.ts index 9bf149b69..8c394ec22 100644 --- a/packages/server/src/server/file-explorer/service.test.ts +++ b/packages/server/src/server/file-explorer/service.test.ts @@ -98,6 +98,27 @@ describe("file explorer service", () => { } }); + it("allows home to be the scoped root for tilde file previews", async () => { + const root = await createHomeTempDir(".paseo-file-explorer-home-root-"); + + try { + const filePath = path.join(root, "sample.txt"); + await writeFile(filePath, "hello from home root\n", "utf-8"); + + const tildePath = `~/${path.relative(os.homedir(), filePath)}`; + const result = await readExplorerFile({ + root: "~", + relativePath: tildePath, + }); + + expect(result.kind).toBe("text"); + expect(result.path).toBe(path.relative(os.homedir(), filePath).split(path.sep).join("/")); + expect(result.content).toBe("hello from home root\n"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("rejects ~-prefixed paths that resolve outside the workspace", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "paseo-file-explorer-outside-home-")); diff --git a/packages/server/src/server/file-explorer/service.ts b/packages/server/src/server/file-explorer/service.ts index d97a9e40e..9457130ad 100644 --- a/packages/server/src/server/file-explorer/service.ts +++ b/packages/server/src/server/file-explorer/service.ts @@ -1,7 +1,7 @@ import { constants, promises as fs } from "fs"; import type { FileHandle } from "fs/promises"; import path from "path"; -import { resolvePathFromBase } from "../path-utils.js"; +import { expandUserPath, resolvePathFromBase } from "../path-utils.js"; export type ExplorerEntryKind = "file" | "directory"; export type ExplorerFileKind = "text" | "image" | "binary"; @@ -276,7 +276,7 @@ async function resolveScopedPath({ root, relativePath = ".", }: ScopedPathParams): Promise { - const normalizedRoot = path.resolve(root); + const normalizedRoot = expandUserPath(root); const requestedPath = resolvePathFromBase(normalizedRoot, relativePath); const relative = path.relative(normalizedRoot, requestedPath); @@ -335,8 +335,8 @@ function isOutsideWorkspaceError(error: unknown): boolean { } function normalizeRelativePath({ root, targetPath }: { root: string; targetPath: string }): string { - const normalizedRoot = path.resolve(root); - const normalizedTarget = path.resolve(targetPath); + const normalizedRoot = expandUserPath(root); + const normalizedTarget = expandUserPath(targetPath); const relative = path.relative(normalizedRoot, normalizedTarget); return relative === "" ? "." : relative.split(path.sep).join("/"); }