dev-mcps/coding-agent-mcp/src/workspace-service.js

807 lines
23 KiB
JavaScript

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