581 lines
17 KiB
JavaScript
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();
|
|
}
|
|
});
|