mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
@@ -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 <password>` and every WebSocket upgrade must include a `Sec-WebSocket-Protocol: paseo.bearer.<password>` 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.
|
||||
|
||||
@@ -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: ".",
|
||||
|
||||
@@ -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}//`);
|
||||
|
||||
@@ -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<string, DirectorySuggestionEn
|
||||
matchMode: "suffix";
|
||||
limit: number;
|
||||
}> = [];
|
||||
const getDirectorySuggestions = vi.fn<GetDirectorySuggestions>(
|
||||
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<GetDirectorySuggestions>(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"));
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
74
packages/app/src/file-explorer/preview-target.test.ts
Normal file
74
packages/app/src/file-explorer/preview-target.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
107
packages/app/src/file-explorer/preview-target.ts
Normal file
107
packages/app/src/file-explorer/preview-target.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
|
||||
@@ -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<ScopedPath> {
|
||||
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("/");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user