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

581 lines
17 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
import { WorkspaceService } from "../src/workspace-service.js";
async function makeWorkspace() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "workspace-mcp-"));
return {
root,
cleanup: async () => {
await fs.rm(root, { recursive: true, force: true });
}
};
}
test("write_file and read_file operate within the workspace root", async () => {
const workspace = await makeWorkspace();
try {
const service = new WorkspaceService({ roots: [workspace.root] });
const writeResult = await service.writeFile({
path: "src/example.txt",
content: "hello workspace"
});
assert.equal(writeResult.created, true);
const readResult = await service.readFile({ path: "src/example.txt" });
assert.equal(readResult.content, "hello workspace");
assert.equal(readResult.path, "src/example.txt");
} finally {
await workspace.cleanup();
}
});
test("session context does not add a directory to workspace paths", async () => {
const workspace = await makeWorkspace();
try {
const service = new WorkspaceService({ roots: [workspace.root] });
const context = { sessionId: "sess_123" };
await service.writeFile(
{
path: "note.txt",
content: "session-bound"
},
context
);
const stored = await fs.readFile(path.join(workspace.root, "note.txt"), "utf8");
assert.equal(stored, "session-bound");
const readResult = await service.readFile({ path: "note.txt" }, context);
assert.equal(readResult.content, "session-bound");
} finally {
await workspace.cleanup();
}
});
test("workspace context supports an additional subpath independent of session ID", async () => {
const workspace = await makeWorkspace();
try {
const service = new WorkspaceService({ roots: [workspace.root] });
const context = {
sessionId: "sess_789",
workspaceSubpath: "feature-a/sources"
};
await service.writeFile(
{
path: "note.txt",
content: "nested-session-bound"
},
context
);
const stored = await fs.readFile(
path.join(workspace.root, "feature-a", "sources", "note.txt"),
"utf8"
);
assert.equal(stored, "nested-session-bound");
} finally {
await workspace.cleanup();
}
});
test("ensureWorkspace creates the execution subpath before the first tool call", async () => {
const workspace = await makeWorkspace();
try {
const service = new WorkspaceService({ roots: [workspace.root] });
const result = service.ensureWorkspace({
sessionId: "sess_789",
workspaceSubpath: "execution-123"
});
assert.equal(result.workspaceSubpath, "execution-123");
assert.equal(fsSync.statSync(path.join(workspace.root, "execution-123")).isDirectory(), true);
} finally {
await workspace.cleanup();
}
});
test("workspace context rejects traversal in workspaceSubpath", async () => {
const workspace = await makeWorkspace();
try {
const service = new WorkspaceService({ roots: [workspace.root] });
await assert.rejects(
service.writeFile(
{
path: "note.txt",
content: "x"
},
{
sessionId: "sess_bad",
workspaceSubpath: "../escape"
}
),
/workspaceSubpath/
);
} finally {
await workspace.cleanup();
}
});
test("read_binary_file and write_binary_file round-trip base64 content", async () => {
const workspace = await makeWorkspace();
try {
const service = new WorkspaceService({ roots: [workspace.root] });
const original = Buffer.from([0, 1, 2, 255, 42]);
const writeResult = await service.writeBinaryFile({
path: "assets/blob.bin",
contentBase64: original.toString("base64")
});
assert.equal(writeResult.created, true);
const readResult = await service.readBinaryFile({ path: "assets/blob.bin" });
assert.equal(readResult.encoding, "base64");
assert.deepEqual(Buffer.from(readResult.contentBase64, "base64"), original);
} finally {
await workspace.cleanup();
}
});
test("list_files returns recursive directory entries", async () => {
const workspace = await makeWorkspace();
try {
await fs.mkdir(path.join(workspace.root, "a", "b"), { recursive: true });
await fs.writeFile(path.join(workspace.root, "a", "b", "file.txt"), "data", "utf8");
const service = new WorkspaceService({ roots: [workspace.root] });
const result = await service.listFiles({ path: "a", recursive: true });
assert.deepEqual(
result.entries.map((entry) => entry.path),
["a/b", "a/b/file.txt"]
);
} finally {
await workspace.cleanup();
}
});
test("list_files reports an absent path without failing the tool call", async () => {
const workspace = await makeWorkspace();
try {
const service = new WorkspaceService({ roots: [workspace.root] });
const result = await service.listFiles({ path: ".orchestrator" });
assert.deepEqual(result, {
root: workspace.root,
path: ".orchestrator",
exists: false,
entries: [],
truncated: false
});
} finally {
await workspace.cleanup();
}
});
test("an empty workspace path is normalized to the workspace root", async () => {
const workspace = await makeWorkspace();
try {
await fs.writeFile(path.join(workspace.root, "root.txt"), "root", "utf8");
const service = new WorkspaceService({ roots: [workspace.root] });
const listed = await service.listFiles({ path: "" });
const info = await service.fileInfo({ path: "" });
assert.equal(listed.path, ".");
assert.equal(listed.entries[0].path, "root.txt");
assert.equal(info.path, ".");
assert.equal(info.type, "directory");
} finally {
await workspace.cleanup();
}
});
test("search_text finds matches with line and column information", async () => {
const workspace = await makeWorkspace();
try {
await fs.mkdir(path.join(workspace.root, "src"), { recursive: true });
await fs.writeFile(path.join(workspace.root, "src", "one.txt"), "alpha\nbeta alpha\n", "utf8");
const service = new WorkspaceService({ roots: [workspace.root] });
const result = await service.searchText({ query: "alpha" });
assert.equal(result.matches.length, 2);
assert.deepEqual(result.matches[0], {
path: "src/one.txt",
line: 1,
column: 1,
preview: "alpha"
});
assert.deepEqual(result.matches[1], {
path: "src/one.txt",
line: 2,
column: 6,
preview: "beta alpha"
});
} finally {
await workspace.cleanup();
}
});
test("apply_patch updates an existing file", async () => {
const workspace = await makeWorkspace();
try {
await fs.writeFile(path.join(workspace.root, "notes.txt"), "one\ntwo\nthree\n", "utf8");
const service = new WorkspaceService({ roots: [workspace.root] });
const patch = [
"*** Begin Patch",
"*** Update File: notes.txt",
"@@",
" one",
"-two",
"+TWO",
" three",
"*** End Patch"
].join("\n");
const result = await service.applyPatch({ path: "notes.txt", patch });
assert.equal(result.action, "update");
assert.equal(result.hunksApplied, 1);
const updated = await fs.readFile(path.join(workspace.root, "notes.txt"), "utf8");
assert.equal(updated, "one\nTWO\nthree\n");
} finally {
await workspace.cleanup();
}
});
test("apply_patch accepts a standard unified diff", async () => {
const workspace = await makeWorkspace();
try {
await fs.writeFile(path.join(workspace.root, "notes.txt"), "one\ntwo\nthree\n", "utf8");
const service = new WorkspaceService({ roots: [workspace.root] });
const patch = [
"diff --git a/notes.txt b/notes.txt",
"index 4cb29ea..c7b5ac4 100644",
"--- a/notes.txt",
"+++ b/notes.txt",
"@@ -1,3 +1,4 @@",
" one",
"-two",
"+TWO",
"+inserted",
" three"
].join("\n");
const result = await service.applyPatch({ path: "notes.txt", patch });
assert.equal(result.action, "update");
assert.equal(result.hunksApplied, 1);
assert.equal(await fs.readFile(path.join(workspace.root, "notes.txt"), "utf8"), "one\nTWO\ninserted\nthree\n");
} finally {
await workspace.cleanup();
}
});
test("a unified diff can insert a line without surrounding context", async () => {
const workspace = await makeWorkspace();
try {
await fs.writeFile(path.join(workspace.root, "notes.txt"), "one\ntwo\n", "utf8");
const service = new WorkspaceService({ roots: [workspace.root] });
const patch = [
"--- a/notes.txt",
"+++ b/notes.txt",
"@@ -1,0 +2 @@",
"+inserted"
].join("\n");
await service.applyPatch({ path: "notes.txt", patch });
assert.equal(await fs.readFile(path.join(workspace.root, "notes.txt"), "utf8"), "one\ninserted\ntwo\n");
} finally {
await workspace.cleanup();
}
});
test("apply_patch can add a new file", async () => {
const workspace = await makeWorkspace();
try {
await fs.mkdir(path.join(workspace.root, "src"), { recursive: true });
const service = new WorkspaceService({ roots: [workspace.root] });
const patch = [
"*** Begin Patch",
"*** Add File: src/new.txt",
"@@",
"+hello",
"+world",
"*** End Patch"
].join("\n");
const result = await service.applyPatch({ path: "src/new.txt", patch });
assert.equal(result.action, "add");
const created = await fs.readFile(path.join(workspace.root, "src", "new.txt"), "utf8");
assert.equal(created, "hello\nworld\n");
} finally {
await workspace.cleanup();
}
});
test("apply_patch can delete an existing file", async () => {
const workspace = await makeWorkspace();
try {
await fs.writeFile(path.join(workspace.root, "obsolete.txt"), "gone\nsoon\n", "utf8");
const service = new WorkspaceService({ roots: [workspace.root] });
const patch = [
"*** Begin Patch",
"*** Delete File: obsolete.txt",
"@@",
"-gone",
"-soon",
"*** End Patch"
].join("\n");
const result = await service.applyPatch({ path: "obsolete.txt", patch });
assert.equal(result.action, "delete");
assert.equal(fsSync.existsSync(path.join(workspace.root, "obsolete.txt")), false);
} finally {
await workspace.cleanup();
}
});
test("file_info returns metadata without reading file content", async () => {
const workspace = await makeWorkspace();
try {
await fs.writeFile(path.join(workspace.root, "meta.txt"), "content", "utf8");
const service = new WorkspaceService({ roots: [workspace.root] });
const info = await service.fileInfo({ path: "meta.txt" });
assert.equal(info.exists, true);
assert.equal(info.type, "file");
assert.equal(info.path, "meta.txt");
assert.equal(typeof info.size, "number");
} finally {
await workspace.cleanup();
}
});
test("file_info reports an absent path without failing the tool call", async () => {
const workspace = await makeWorkspace();
try {
const service = new WorkspaceService({ roots: [workspace.root] });
const info = await service.fileInfo({ path: ".orchestrator" });
assert.deepEqual(info, {
root: workspace.root,
path: ".orchestrator",
exists: false
});
} finally {
await workspace.cleanup();
}
});
test("delete_path removes directories only when recursive is enabled", async () => {
const workspace = await makeWorkspace();
try {
await fs.mkdir(path.join(workspace.root, "tree", "child"), { recursive: true });
await fs.writeFile(path.join(workspace.root, "tree", "child", "file.txt"), "x", "utf8");
const service = new WorkspaceService({ roots: [workspace.root] });
await assert.rejects(
service.deletePath({ path: "tree" }),
/recursive=true/
);
const result = await service.deletePath({ path: "tree", recursive: true });
assert.equal(result.deleted, true);
assert.equal(fsSync.existsSync(path.join(workspace.root, "tree")), false);
} finally {
await workspace.cleanup();
}
});
test("move_path relocates files and rename_path renames them", async () => {
const workspace = await makeWorkspace();
try {
await fs.mkdir(path.join(workspace.root, "src"), { recursive: true });
await fs.writeFile(path.join(workspace.root, "src", "before.txt"), "payload", "utf8");
const service = new WorkspaceService({ roots: [workspace.root] });
const moved = await service.movePath({
fromPath: "src/before.txt",
toPath: "dist/after.txt"
});
assert.equal(moved.moved, true);
assert.equal(fsSync.existsSync(path.join(workspace.root, "src", "before.txt")), false);
assert.equal(fsSync.existsSync(path.join(workspace.root, "dist", "after.txt")), true);
const renamed = await service.renamePath({
path: "dist/after.txt",
newName: "final.txt"
});
assert.equal(renamed.moved, true);
assert.equal(fsSync.existsSync(path.join(workspace.root, "dist", "after.txt")), false);
assert.equal(fsSync.existsSync(path.join(workspace.root, "dist", "final.txt")), true);
} finally {
await workspace.cleanup();
}
});
test("move_path can overwrite an existing destination when requested", async () => {
const workspace = await makeWorkspace();
try {
await fs.mkdir(path.join(workspace.root, "a"), { recursive: true });
await fs.mkdir(path.join(workspace.root, "b"), { recursive: true });
await fs.writeFile(path.join(workspace.root, "a", "from.txt"), "new", "utf8");
await fs.writeFile(path.join(workspace.root, "b", "to.txt"), "old", "utf8");
const service = new WorkspaceService({ roots: [workspace.root] });
const result = await service.movePath({
fromPath: "a/from.txt",
toPath: "b/to.txt",
overwrite: true
});
assert.equal(result.overwritten, true);
const content = await fs.readFile(path.join(workspace.root, "b", "to.txt"), "utf8");
assert.equal(content, "new");
} finally {
await workspace.cleanup();
}
});
test("path traversal outside the root is rejected", async () => {
const workspace = await makeWorkspace();
try {
const service = new WorkspaceService({ roots: [workspace.root] });
await assert.rejects(
service.readFile({ path: "../etc/passwd" }),
/escapes allowed workspace root/
);
} finally {
await workspace.cleanup();
}
});
test("absolute paths and caller-selected roots are rejected", async () => {
const workspace = await makeWorkspace();
try {
const service = new WorkspaceService({ roots: [workspace.root] });
await assert.rejects(
service.writeFile({ path: path.join(workspace.root, "outside.txt"), content: "x" }),
/relative path/
);
await assert.rejects(
service.writeFile({ path: "inside.txt", root: workspace.root, content: "x" }),
/Selecting a workspace root/
);
} finally {
await workspace.cleanup();
}
});
test("read_file rejects direct symlink targets", async () => {
const workspace = await makeWorkspace();
try {
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "workspace-mcp-outside-"));
const outsideFile = path.join(outsideDir, "secret.txt");
await fs.writeFile(outsideFile, "secret", "utf8");
await fs.symlink(outsideFile, path.join(workspace.root, "link.txt"));
const service = new WorkspaceService({ roots: [workspace.root] });
await assert.rejects(
service.readFile({ path: "link.txt" }),
/Symlink targets are not allowed/
);
await fs.rm(outsideDir, { recursive: true, force: true });
} finally {
await workspace.cleanup();
}
});
test("read_file rejects symlinked parent segments", async () => {
const workspace = await makeWorkspace();
try {
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "workspace-mcp-outside-parent-"));
await fs.writeFile(path.join(outsideDir, "secret.txt"), "secret", "utf8");
await fs.symlink(outsideDir, path.join(workspace.root, "linked-dir"));
const service = new WorkspaceService({ roots: [workspace.root] });
await assert.rejects(
service.readFile({ path: "linked-dir/secret.txt" }),
/Symlink path segments are not allowed/
);
await fs.rm(outsideDir, { recursive: true, force: true });
} finally {
await workspace.cleanup();
}
});
test("make_directory creates directories safely", async () => {
const workspace = await makeWorkspace();
try {
const service = new WorkspaceService({ roots: [workspace.root] });
const result = await service.makeDirectory({ path: "nested/path" });
assert.equal(result.created, true);
assert.equal(fsSync.existsSync(path.join(workspace.root, "nested", "path")), true);
} finally {
await workspace.cleanup();
}
});
test("delete_path refuses to delete the workspace root", async () => {
const workspace = await makeWorkspace();
try {
const service = new WorkspaceService({ roots: [workspace.root] });
await assert.rejects(
service.deletePath({ path: "." }),
/workspace root/
);
} finally {
await workspace.cleanup();
}
});