import fs from "node:fs/promises"; import fsSync from "node:fs"; import path from "node:path"; import { applyTextPatch } from "./patch.js"; function assertNonEmptyString(value, label) { if (typeof value !== "string" || value.trim() === "") { throw new Error(`${label} must be a non-empty string`); } } // MCP clients sometimes serialize an omitted root path as an empty string. // Treat that spelling as the workspace root rather than making a harmless // discovery request fail. function normalizeRootPath(value) { return typeof value === "string" && value.trim() === "" ? "." : value; } function normalizeWorkspaceSubpath(workspaceSubpath) { if (!workspaceSubpath) { return null; } const normalized = String(workspaceSubpath).trim().replace(/\\/g, "/"); if (normalized === "") { return null; } if (normalized.startsWith("/")) { throw new Error("workspaceSubpath must be relative"); } const segments = normalized.split("/").filter(Boolean); if (segments.length === 0) { return null; } for (const segment of segments) { if (segment === "." || segment === "..") { throw new Error("workspaceSubpath must not contain traversal segments"); } if (!/^[A-Za-z0-9._-]+$/.test(segment)) { throw new Error("workspaceSubpath contains unsupported characters"); } } return segments.join(path.sep); } function isWithinPath(targetPath, rootPath) { const relative = path.relative(rootPath, targetPath); return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } function normalizeClientPath(rootRecord, absolutePath) { const relative = path.relative(rootRecord.resolved, absolutePath); return (relative === "" ? "." : relative).split(path.sep).join("/"); } function looksBinary(buffer) { return buffer.includes(0); } function findAllIndexes(text, query, caseSensitive) { const haystack = caseSensitive ? text : text.toLowerCase(); const needle = caseSensitive ? query : query.toLowerCase(); const indexes = []; let fromIndex = 0; while (fromIndex <= haystack.length) { const matchIndex = haystack.indexOf(needle, fromIndex); if (matchIndex === -1) { break; } indexes.push(matchIndex); fromIndex = matchIndex + Math.max(needle.length, 1); } return indexes; } function getPathType(stats) { if (stats.isDirectory()) { return "directory"; } if (stats.isFile()) { return "file"; } if (stats.isSymbolicLink()) { return "symlink"; } return "other"; } function decodeBase64(contentBase64) { assertNonEmptyString(contentBase64, "contentBase64"); const normalized = contentBase64.replace(/\s+/g, ""); if (normalized.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) { throw new Error("contentBase64 must be valid base64"); } return Buffer.from(normalized, "base64"); } async function readTextFile(absolutePath) { const buffer = await fs.readFile(absolutePath); if (looksBinary(buffer)) { throw new Error("Binary files are not supported"); } return buffer.toString("utf8"); } export class WorkspaceService { constructor({ roots }) { if (!Array.isArray(roots) || roots.length === 0) { throw new Error("At least one workspace root is required"); } this.roots = roots.map((rootPath) => { assertNonEmptyString(rootPath, "root"); const resolved = path.resolve(rootPath); if (!fsSync.existsSync(resolved)) { throw new Error(`Workspace root does not exist: ${resolved}`); } if (!fsSync.statSync(resolved).isDirectory()) { throw new Error(`Workspace root must be a directory: ${resolved}`); } return { configured: rootPath, resolved, real: fsSync.realpathSync(resolved) }; }); } getPrimaryRoot() { return this.roots[0]; } ensureWorkspace(context = {}) { const rootRecord = this.getScopedRoot(null, context, { createIfMissing: true }); return { root: rootRecord.resolved, workspaceSubpath: rootRecord.workspaceSubpath }; } getScopedRoot(rootHint, context, { createIfMissing = false } = {}) { const baseRootRecord = this.selectRoot(rootHint); const workspaceSubpath = normalizeWorkspaceSubpath(context?.workspaceSubpath ?? null); let resolved = baseRootRecord.resolved; if (workspaceSubpath) { resolved = path.resolve(resolved, workspaceSubpath); } if (!isWithinPath(resolved, baseRootRecord.resolved)) { throw new Error("Resolved session root escapes the allowed workspace root"); } if (createIfMissing && !fsSync.existsSync(resolved)) { fsSync.mkdirSync(resolved, { recursive: true }); } const real = fsSync.existsSync(resolved) ? fsSync.realpathSync(resolved) : resolved; return { configured: baseRootRecord.configured, resolved, real, baseRoot: baseRootRecord.resolved, sessionId: context?.sessionId ?? null, workspaceSubpath: workspaceSubpath }; } selectRoot(rootHint) { if (!rootHint) { return this.getPrimaryRoot(); } const matched = this.roots.find( (rootRecord) => rootHint === rootRecord.configured || rootHint === rootRecord.resolved || rootHint === rootRecord.real ); if (!matched) { throw new Error(`Unknown root: ${rootHint}`); } return matched; } resolvePath(inputPath, { root, context, createSessionRoot = false } = {}) { inputPath = normalizeRootPath(inputPath); assertNonEmptyString(inputPath, "path"); if (inputPath.includes("\0") || path.isAbsolute(inputPath)) { throw new Error("Path must be a relative path"); } if (root) { throw new Error("Selecting a workspace root from a tool call is not allowed"); } const rootRecord = this.getScopedRoot(null, context, { createIfMissing: createSessionRoot }); const absolutePath = path.resolve(rootRecord.resolved, inputPath); if (!isWithinPath(absolutePath, rootRecord.resolved)) { throw new Error(`Path escapes allowed workspace root: ${inputPath}`); } return { rootRecord, absolutePath, clientPath: normalizeClientPath(rootRecord, absolutePath) }; } ensureNotRootPath(absolutePath, rootRecord, action) { if (path.resolve(absolutePath) === rootRecord.resolved) { throw new Error(`Refusing to ${action} the workspace root`); } } async validatePathChain(targetPath, rootRecord) { if (!isWithinPath(targetPath, rootRecord.resolved)) { throw new Error("Resolved path escapes the allowed workspace root"); } const relativePath = path.relative(rootRecord.resolved, targetPath); if (relativePath === "") { return { firstMissingPath: null }; } const segments = relativePath.split(path.sep).filter(Boolean); let currentPath = rootRecord.resolved; for (const segment of segments) { const nextPath = path.join(currentPath, segment); if (!fsSync.existsSync(nextPath)) { return { firstMissingPath: nextPath }; } const stats = await fs.lstat(nextPath); if (stats.isSymbolicLink()) { throw new Error(`Symlink path segments are not allowed: ${normalizeClientPath(rootRecord, nextPath)}`); } const realPath = await fs.realpath(nextPath); if (!isWithinPath(realPath, rootRecord.real)) { throw new Error("Resolved path escapes the allowed workspace root"); } currentPath = nextPath; } return { firstMissingPath: null }; } async assertSafeExistingPath(absolutePath, rootRecord, { allowSymlink = false } = {}) { const chainTarget = absolutePath === rootRecord.resolved ? absolutePath : path.dirname(absolutePath); await this.validatePathChain(chainTarget, rootRecord); const stats = await fs.lstat(absolutePath); if (stats.isSymbolicLink()) { if (!allowSymlink) { throw new Error("Symlink targets are not allowed for this operation"); } return stats; } const realTarget = await fs.realpath(absolutePath); if (!isWithinPath(realTarget, rootRecord.real)) { throw new Error("Resolved path escapes the allowed workspace root"); } return stats; } async prepareDestination(absolutePath, rootRecord, { createParents = true } = {}) { const parentPath = path.dirname(absolutePath); const { firstMissingPath } = await this.validatePathChain(parentPath, rootRecord); if (firstMissingPath && !createParents) { throw new Error(`Parent directory does not exist: ${normalizeClientPath(rootRecord, firstMissingPath)}`); } if (createParents) { await fs.mkdir(parentPath, { recursive: true }); } await this.validatePathChain(parentPath, rootRecord); } async writeBufferFile({ path: targetPath, root, buffer, createParents = true, context = {} }) { const { rootRecord, absolutePath, clientPath } = this.resolvePath(targetPath, { root, context, createSessionRoot: true }); const existed = fsSync.existsSync(absolutePath); if (existed) { const stats = await this.assertSafeExistingPath(absolutePath, rootRecord); if (!stats.isFile()) { throw new Error("Path is not a regular file"); } } else { await this.prepareDestination(absolutePath, rootRecord, { createParents }); } await fs.writeFile(absolutePath, buffer); return { root: rootRecord.resolved, path: clientPath, bytesWritten: buffer.length, created: !existed }; } async listFiles({ path: targetPath = ".", root, recursive = false, maxEntries = 500 } = {}, context = {}) { const { rootRecord, absolutePath, clientPath } = this.resolvePath(targetPath, { root, context }); const chainTarget = absolutePath === rootRecord.resolved ? absolutePath : path.dirname(absolutePath); await this.validatePathChain(chainTarget, rootRecord); if (!fsSync.existsSync(absolutePath)) { return { root: rootRecord.resolved, path: clientPath, exists: false, entries: [], truncated: false }; } const stats = await this.assertSafeExistingPath(absolutePath, rootRecord); const entries = []; const queue = [{ absolutePath, clientPath }]; let truncated = false; if (!stats.isDirectory()) { return { root: rootRecord.resolved, path: clientPath, exists: true, entries: [ { path: clientPath, type: getPathType(stats), size: stats.size, mtimeMs: stats.mtimeMs } ], truncated }; } while (queue.length > 0 && entries.length < maxEntries) { const current = queue.shift(); const currentEntries = await fs.readdir(current.absolutePath, { withFileTypes: true }); currentEntries.sort((left, right) => left.name.localeCompare(right.name)); for (const dirent of currentEntries) { const childAbsolute = path.join(current.absolutePath, dirent.name); const childClient = current.clientPath === "." ? dirent.name : `${current.clientPath}/${dirent.name}`; const childStats = await fs.lstat(childAbsolute); entries.push({ path: childClient, type: dirent.isDirectory() ? "directory" : dirent.isFile() ? "file" : dirent.isSymbolicLink() ? "symlink" : "other", size: childStats.size, mtimeMs: childStats.mtimeMs }); if (entries.length >= maxEntries) { truncated = true; break; } if (recursive && dirent.isDirectory() && !dirent.isSymbolicLink()) { queue.push({ absolutePath: childAbsolute, clientPath: childClient }); } } } return { root: rootRecord.resolved, path: clientPath, exists: true, entries, truncated }; } async readFile({ path: targetPath, root } = {}, context = {}) { const { rootRecord, absolutePath, clientPath } = this.resolvePath(targetPath, { root, context }); const stats = await this.assertSafeExistingPath(absolutePath, rootRecord); if (!stats.isFile()) { throw new Error("Path is not a file"); } const content = await readTextFile(absolutePath); return { root: rootRecord.resolved, path: clientPath, size: stats.size, content }; } async readBinaryFile({ path: targetPath, root } = {}, context = {}) { const { rootRecord, absolutePath, clientPath } = this.resolvePath(targetPath, { root, context }); const stats = await this.assertSafeExistingPath(absolutePath, rootRecord); if (!stats.isFile()) { throw new Error("Path is not a file"); } const content = await fs.readFile(absolutePath); return { root: rootRecord.resolved, path: clientPath, size: stats.size, encoding: "base64", contentBase64: content.toString("base64") }; } async readFiles({ paths, root } = {}, context = {}) { if (!Array.isArray(paths) || paths.length === 0) { throw new Error("paths must be a non-empty array"); } const files = []; for (const targetPath of paths) { files.push(await this.readFile({ path: targetPath, root }, context)); } return { files }; } async searchText({ path: targetPath = ".", root, query, maxMatches = 200, caseSensitive = false } = {}, context = {}) { assertNonEmptyString(query, "query"); const { rootRecord, absolutePath, clientPath } = this.resolvePath(targetPath, { root, context }); await this.assertSafeExistingPath(absolutePath, rootRecord); const matches = []; const queue = [{ absolutePath, clientPath }]; let truncated = false; while (queue.length > 0 && matches.length < maxMatches) { const current = queue.shift(); const currentStats = await fs.lstat(current.absolutePath); if (currentStats.isDirectory()) { const children = await fs.readdir(current.absolutePath, { withFileTypes: true }); children.sort((left, right) => left.name.localeCompare(right.name)); for (const dirent of children) { if (dirent.isSymbolicLink()) { continue; } const childAbsolute = path.join(current.absolutePath, dirent.name); const childClient = current.clientPath === "." ? dirent.name : `${current.clientPath}/${dirent.name}`; queue.push({ absolutePath: childAbsolute, clientPath: childClient }); } continue; } if (!currentStats.isFile()) { continue; } const buffer = await fs.readFile(current.absolutePath); if (looksBinary(buffer)) { continue; } const text = buffer.toString("utf8"); const lines = text.replace(/\r\n/g, "\n").split("\n"); for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { const indexes = findAllIndexes(lines[lineIndex], query, caseSensitive); for (const columnIndex of indexes) { matches.push({ path: current.clientPath, line: lineIndex + 1, column: columnIndex + 1, preview: lines[lineIndex] }); if (matches.length >= maxMatches) { truncated = true; break; } } if (truncated) { break; } } } return { root: rootRecord.resolved, path: clientPath, query, matches, truncated }; } async writeFile({ path: targetPath, root, content, createParents = true } = {}, context = {}) { if (typeof content !== "string") { throw new Error("content must be a string"); } return this.writeBufferFile({ path: targetPath, root, buffer: Buffer.from(content, "utf8"), createParents, context }); } async writeBinaryFile({ path: targetPath, root, contentBase64, createParents = true } = {}, context = {}) { return this.writeBufferFile({ path: targetPath, root, buffer: decodeBase64(contentBase64), createParents, context }); } async applyPatch({ path: targetPath, root, patch } = {}, context = {}) { assertNonEmptyString(patch, "patch"); const { rootRecord, absolutePath, clientPath } = this.resolvePath(targetPath, { root, context, createSessionRoot: true }); const fileExists = fsSync.existsSync(absolutePath); if (fileExists) { const stats = await this.assertSafeExistingPath(absolutePath, rootRecord); if (!stats.isFile()) { throw new Error("Path is not a regular file"); } } else { await this.prepareDestination(absolutePath, rootRecord, { createParents: false }); } const originalText = fileExists ? await readTextFile(absolutePath) : ""; const patchResult = applyTextPatch({ originalText, patchText: patch, expectedPath: clientPath, fileExists }); if (patchResult.action === "delete") { await fs.rm(absolutePath, { force: false }); return { root: rootRecord.resolved, path: clientPath, action: "delete", hunksApplied: patchResult.hunksApplied }; } await fs.writeFile(absolutePath, patchResult.updatedText, "utf8"); return { root: rootRecord.resolved, path: clientPath, action: patchResult.action, bytesWritten: Buffer.byteLength(patchResult.updatedText, "utf8"), hunksApplied: patchResult.hunksApplied }; } async makeDirectory({ path: targetPath, root } = {}, context = {}) { const { rootRecord, absolutePath, clientPath } = this.resolvePath(targetPath, { root, context, createSessionRoot: true }); const existed = fsSync.existsSync(absolutePath); if (existed) { const stats = await this.assertSafeExistingPath(absolutePath, rootRecord); if (!stats.isDirectory()) { throw new Error("Path exists and is not a directory"); } return { root: rootRecord.resolved, path: clientPath, created: false }; } await this.prepareDestination(absolutePath, rootRecord, { createParents: true }); await fs.mkdir(absolutePath, { recursive: true }); await this.assertSafeExistingPath(absolutePath, rootRecord); return { root: rootRecord.resolved, path: clientPath, created: true }; } async deletePath({ path: targetPath, root, recursive = false, allowMissing = false } = {}, context = {}) { const { rootRecord, absolutePath, clientPath } = this.resolvePath(targetPath, { root, context }); this.ensureNotRootPath(absolutePath, rootRecord, "delete"); if (!fsSync.existsSync(absolutePath)) { if (allowMissing) { return { root: rootRecord.resolved, path: clientPath, deleted: false, missing: true }; } throw new Error("Path does not exist"); } const stats = await this.assertSafeExistingPath(absolutePath, rootRecord, { allowSymlink: true }); if (stats.isDirectory() && !recursive) { throw new Error("Refusing to delete a directory without recursive=true"); } await fs.rm(absolutePath, { recursive: stats.isDirectory(), force: false }); return { root: rootRecord.resolved, path: clientPath, deleted: true, type: getPathType(stats) }; } async movePath({ fromPath, toPath, root, overwrite = false, createParents = true } = {}, context = {}) { assertNonEmptyString(fromPath, "fromPath"); assertNonEmptyString(toPath, "toPath"); const source = this.resolvePath(fromPath, { root, context }); const destination = this.resolvePath(toPath, { root, context, createSessionRoot: true }); this.ensureNotRootPath(source.absolutePath, source.rootRecord, "move"); if (source.absolutePath === destination.absolutePath) { return { fromRoot: source.rootRecord.resolved, fromPath: source.clientPath, toRoot: destination.rootRecord.resolved, toPath: destination.clientPath, moved: false }; } const sourceStats = await this.assertSafeExistingPath(source.absolutePath, source.rootRecord, { allowSymlink: true }); if ( sourceStats.isDirectory() && isWithinPath(destination.absolutePath, source.absolutePath) ) { throw new Error("Cannot move a directory into itself"); } if (fsSync.existsSync(destination.absolutePath)) { const destinationStats = await this.assertSafeExistingPath( destination.absolutePath, destination.rootRecord, { allowSymlink: true } ); if (!overwrite) { throw new Error("Destination already exists"); } this.ensureNotRootPath(destination.absolutePath, destination.rootRecord, "overwrite"); await fs.rm(destination.absolutePath, { recursive: destinationStats.isDirectory(), force: false }); } else { await this.prepareDestination(destination.absolutePath, destination.rootRecord, { createParents }); } try { await fs.rename(source.absolutePath, destination.absolutePath); } catch (error) { if (error && typeof error === "object" && "code" in error && error.code === "EXDEV") { throw new Error("Cross-device moves are not supported"); } throw error; } return { fromRoot: source.rootRecord.resolved, fromPath: source.clientPath, toRoot: destination.rootRecord.resolved, toPath: destination.clientPath, moved: true, overwritten: overwrite }; } async renamePath({ path: targetPath, root, newName, overwrite = false } = {}, context = {}) { assertNonEmptyString(newName, "newName"); if ( newName === "." || newName === ".." || newName.includes("/") || newName.includes("\\") || newName.includes(path.sep) ) { throw new Error("newName must be a plain file or directory name"); } const source = this.resolvePath(targetPath, { root, context }); const destinationPath = path.join(path.dirname(source.absolutePath), newName); return this.movePath({ fromPath: source.clientPath, toPath: path.relative(source.rootRecord.resolved, destinationPath), overwrite, createParents: false }, context); } async fileInfo({ path: targetPath, root } = {}, context = {}) { const { rootRecord, absolutePath, clientPath } = this.resolvePath(targetPath, { root, context }); const chainTarget = absolutePath === rootRecord.resolved ? absolutePath : path.dirname(absolutePath); await this.validatePathChain(chainTarget, rootRecord); if (!fsSync.existsSync(absolutePath)) { return { root: rootRecord.resolved, path: clientPath, exists: false }; } const stats = await this.assertSafeExistingPath(absolutePath, rootRecord, { allowSymlink: true }); return { root: rootRecord.resolved, path: clientPath, exists: true, type: getPathType(stats), size: stats.size, mtimeMs: stats.mtimeMs, ctimeMs: stats.ctimeMs }; } }