Merge pull request #105 from DenchHQ/bp/4-workspace-path-resolution
refactor(workspace): add unified path resolution with browse mode support
This commit is contained in:
commit
cacecda172
53
apps/web/lib/workspace-paths.ts
Normal file
53
apps/web/lib/workspace-paths.ts
Normal file
@ -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";
|
||||
}
|
||||
@ -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", () => {
|
||||
|
||||
@ -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<T = Record<string, unknown>>(
|
||||
}
|
||||
}
|
||||
|
||||
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<T = Record<string, unknown>>(
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user