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:
Kumar Abhirup 2026-03-15 04:20:33 -07:00 committed by GitHub
commit cacecda172
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 174 additions and 25 deletions

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

View 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", () => {

View File

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