From acc80615c42920a33fe7a67650b3f51608e85f65 Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Sun, 15 Mar 2026 04:16:40 -0700 Subject: [PATCH] refactor(workspace): add unified path resolution with browse mode support resolveFilesystemPath replaces ad-hoc path helpers with a single resolver for absolute, home-relative, and workspace-relative paths. --- apps/web/lib/workspace-paths.ts | 53 ++++++++++++++++ apps/web/lib/workspace.test.ts | 40 ++++++++++++ apps/web/lib/workspace.ts | 106 ++++++++++++++++++++++++-------- 3 files changed, 174 insertions(+), 25 deletions(-) create mode 100644 apps/web/lib/workspace-paths.ts diff --git a/apps/web/lib/workspace-paths.ts b/apps/web/lib/workspace-paths.ts new file mode 100644 index 00000000000..6f93ae1f304 --- /dev/null +++ b/apps/web/lib/workspace-paths.ts @@ -0,0 +1,53 @@ +export type WorkspacePathKind = + | "virtual" + | "workspaceRelative" + | "homeRelative" + | "absolute"; + +export function isHomeRelativePath(path: string): boolean { + return path.startsWith("~/"); +} + +export function isVirtualPath(path: string): boolean { + return path.startsWith("~") && !isHomeRelativePath(path); +} + +export function isAbsolutePath(path: string): boolean { + return path.startsWith("/"); +} + +export function classifyWorkspacePath(path: string): WorkspacePathKind { + if (isVirtualPath(path)) {return "virtual";} + if (isHomeRelativePath(path)) {return "homeRelative";} + if (isAbsolutePath(path)) {return "absolute";} + return "workspaceRelative"; +} + +export function isBrowsePath(path: string): boolean { + const kind = classifyWorkspacePath(path); + return kind === "absolute" || kind === "homeRelative"; +} + +export function fileReadUrl(path: string): string { + const kind = classifyWorkspacePath(path); + if (kind === "virtual") { + return `/api/workspace/virtual-file?path=${encodeURIComponent(path)}`; + } + if (kind === "absolute" || kind === "homeRelative") { + return `/api/workspace/browse-file?path=${encodeURIComponent(path)}`; + } + return `/api/workspace/file?path=${encodeURIComponent(path)}`; +} + +export function rawFileReadUrl(path: string): string { + if (isBrowsePath(path)) { + return `/api/workspace/browse-file?path=${encodeURIComponent(path)}&raw=true`; + } + return `/api/workspace/raw-file?path=${encodeURIComponent(path)}`; +} + +export function fileWriteUrl(path: string): string { + return classifyWorkspacePath(path) === "virtual" + ? "/api/workspace/virtual-file" + : "/api/workspace/file"; +} diff --git a/apps/web/lib/workspace.test.ts b/apps/web/lib/workspace.test.ts index 8b6fee0cbae..6a232d8f8d4 100644 --- a/apps/web/lib/workspace.test.ts +++ b/apps/web/lib/workspace.test.ts @@ -957,6 +957,46 @@ describe("workspace utilities", () => { }); }); + describe("resolveFilesystemPath", () => { + it("resolves absolute paths outside the workspace without re-rooting them", async () => { + process.env.OPENCLAW_WORKSPACE = WS_DIR; + const { resolveFilesystemPath, mockExists } = await importWorkspace(); + mockExists.mockImplementation((p) => [WS_DIR, "/tmp/note.md"].includes(String(p))); + + expect(resolveFilesystemPath("/tmp/note.md")).toEqual({ + absolutePath: "/tmp/note.md", + kind: "absolute", + withinWorkspace: false, + workspaceRelativePath: null, + }); + }); + + it("expands home-relative paths before resolving them", async () => { + process.env.OPENCLAW_WORKSPACE = WS_DIR; + const { resolveFilesystemPath, mockExists } = await importWorkspace(); + const homePath = "/home/testuser/notes/today.md"; + mockExists.mockImplementation((p) => [WS_DIR, homePath].includes(String(p))); + + expect(resolveFilesystemPath("~/notes/today.md")).toEqual({ + absolutePath: homePath, + kind: "homeRelative", + withinWorkspace: false, + workspaceRelativePath: null, + }); + }); + + it("only treats protected files as system files when they resolve inside the workspace", async () => { + process.env.OPENCLAW_WORKSPACE = WS_DIR; + const { resolveFilesystemPath, isProtectedSystemPath, mockExists } = await importWorkspace(); + const workspaceSystemFile = join(WS_DIR, ".object.yaml"); + const externalSystemFile = "/tmp/.object.yaml"; + mockExists.mockImplementation((p) => [WS_DIR, workspaceSystemFile, externalSystemFile].includes(String(p))); + + expect(isProtectedSystemPath(resolveFilesystemPath(workspaceSystemFile))).toBe(true); + expect(isProtectedSystemPath(resolveFilesystemPath(externalSystemFile))).toBe(false); + }); + }); + // ─── isSystemFile ──────────────────────────────────────────────── describe("isSystemFile", () => { diff --git a/apps/web/lib/workspace.ts b/apps/web/lib/workspace.ts index fe80a1d90fe..a2ae32aea14 100644 --- a/apps/web/lib/workspace.ts +++ b/apps/web/lib/workspace.ts @@ -2,10 +2,15 @@ import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync } from import { access, readdir as readdirAsync } from "node:fs/promises"; import { execSync, exec } from "node:child_process"; import { promisify } from "node:util"; -import { join, resolve, normalize, relative } from "node:path"; +import { join, resolve, normalize, relative, isAbsolute as isNodeAbsolute } from "node:path"; import { homedir } from "node:os"; import YAML from "yaml"; import { normalizeFilterGroup, type SavedView, type ViewTypeSettings } from "./object-filters"; +import { + classifyWorkspacePath, + isHomeRelativePath, + type WorkspacePathKind, +} from "./workspace-paths"; const execAsync = promisify(exec); @@ -1148,6 +1153,66 @@ export async function duckdbQueryOnFileAsync>( } } +export type ResolvedFilesystemPath = { + absolutePath: string; + kind: WorkspacePathKind; + withinWorkspace: boolean; + workspaceRelativePath: string | null; +}; + +function toPortableRelativePath(path: string): string { + return path.replace(/\\/g, "/"); +} + +function isPathWithinRoot(root: string, absolutePath: string): boolean { + const relPath = relative(resolve(root), resolve(absolutePath)); + return relPath === "" || (!relPath.startsWith("..") && !isNodeAbsolute(relPath)); +} + +function expandHomeRelativePath(inputPath: string): string { + if (!isHomeRelativePath(inputPath)) {return inputPath;} + return join(homedir(), inputPath.slice(2)); +} + +/** + * Resolve a local filesystem path that may be workspace-relative, absolute, + * or home-relative. Virtual `~skills/...` style paths are rejected here. + */ +export function resolveFilesystemPath( + inputPath: string, + options: { allowMissing?: boolean } = {}, +): ResolvedFilesystemPath | null { + const kind = classifyWorkspacePath(inputPath); + if (kind === "virtual") {return null;} + + const workspaceRoot = resolveWorkspaceRoot(); + let absolutePath: string; + + if (kind === "workspaceRelative") { + if (!workspaceRoot) {return null;} + absolutePath = resolve(workspaceRoot, normalize(inputPath)); + if (!isPathWithinRoot(workspaceRoot, absolutePath)) {return null;} + } else if (kind === "homeRelative") { + absolutePath = resolve(normalize(expandHomeRelativePath(inputPath))); + } else { + absolutePath = resolve(normalize(inputPath)); + } + + if (!options.allowMissing && !existsSync(absolutePath)) {return null;} + + const withinWorkspace = !!workspaceRoot && isPathWithinRoot(workspaceRoot, absolutePath); + const workspaceRelativePath = withinWorkspace && workspaceRoot + ? toPortableRelativePath(relative(resolve(workspaceRoot), absolutePath)) + : null; + + return { + absolutePath, + kind, + withinWorkspace, + workspaceRelativePath, + }; +} + /** * Validate and resolve a path within the workspace. * Prevents path traversal by ensuring the resolved path stays within root. @@ -1156,20 +1221,9 @@ export async function duckdbQueryOnFileAsync>( export function safeResolvePath( relativePath: string, ): string | null { - const root = resolveWorkspaceRoot(); - if (!root) {return null;} - - // Reject obvious traversal attempts - const normalized = normalize(relativePath); - if (normalized.startsWith("..") || normalized.includes("/../")) {return null;} - - const absolute = resolve(root, normalized); - - // Ensure the resolved path is still within the workspace root - if (!absolute.startsWith(resolve(root))) {return null;} - if (!existsSync(absolute)) {return null;} - - return absolute; + const resolvedPath = resolveFilesystemPath(relativePath); + if (!resolvedPath || resolvedPath.kind !== "workspaceRelative") {return null;} + return resolvedPath.absolutePath; } /** @@ -1427,22 +1481,24 @@ export function isSystemFile(relativePath: string): boolean { return isRoot && ROOT_ONLY_SYSTEM_PATTERNS.some((p) => p.test(base)); } +export function isProtectedSystemPath( + resolvedPath: ResolvedFilesystemPath | null, +): boolean { + if (!resolvedPath?.withinWorkspace || resolvedPath.workspaceRelativePath == null) { + return false; + } + return isSystemFile(resolvedPath.workspaceRelativePath); +} + /** * Like safeResolvePath but does NOT require the target to exist on disk. * Useful for mkdir / create / rename-target validation. * Still prevents path traversal. */ export function safeResolveNewPath(relativePath: string): string | null { - const root = resolveWorkspaceRoot(); - if (!root) {return null;} - - const normalized = normalize(relativePath); - if (normalized.startsWith("..") || normalized.includes("/../")) {return null;} - - const absolute = resolve(root, normalized); - if (!absolute.startsWith(resolve(root))) {return null;} - - return absolute; + const resolvedPath = resolveFilesystemPath(relativePath, { allowMissing: true }); + if (!resolvedPath || resolvedPath.kind !== "workspaceRelative") {return null;} + return resolvedPath.absolutePath; } /**