Merge branch 'main' of github.com:getpaseo/paseo

This commit is contained in:
Mohamed Boudra
2026-05-29 12:20:59 +07:00
11 changed files with 361 additions and 132 deletions

View File

@@ -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.

View File

@@ -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: ".",

View File

@@ -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}//`);

View File

@@ -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"));
});

View File

@@ -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,

View 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();
});
});

View 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,
};
}

View File

@@ -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({

View File

@@ -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,
};
}

View File

@@ -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-"));

View File

@@ -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("/");
}