@dexto/tools-filesystem 1.5.2 → 1.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/directory-approval.integration.test.cjs +36 -32
- package/dist/directory-approval.integration.test.js +36 -32
- package/dist/edit-file-tool.cjs +43 -19
- package/dist/edit-file-tool.js +43 -19
- package/dist/edit-file-tool.test.cjs +203 -0
- package/dist/edit-file-tool.test.d.cts +2 -0
- package/dist/edit-file-tool.test.d.ts +2 -0
- package/dist/edit-file-tool.test.js +180 -0
- package/dist/filesystem-service.cjs +17 -14
- package/dist/filesystem-service.d.cts +3 -3
- package/dist/filesystem-service.d.ts +3 -3
- package/dist/filesystem-service.js +17 -14
- package/dist/filesystem-service.test.cjs +233 -0
- package/dist/filesystem-service.test.d.cts +2 -0
- package/dist/filesystem-service.test.d.ts +2 -0
- package/dist/filesystem-service.test.js +210 -0
- package/dist/path-validator.cjs +29 -20
- package/dist/path-validator.d.cts +9 -2
- package/dist/path-validator.d.ts +9 -2
- package/dist/path-validator.js +29 -20
- package/dist/path-validator.test.cjs +54 -48
- package/dist/path-validator.test.js +54 -48
- package/dist/read-file-tool.cjs +2 -2
- package/dist/read-file-tool.js +2 -2
- package/dist/tool-provider.cjs +22 -7
- package/dist/tool-provider.d.cts +4 -1
- package/dist/tool-provider.d.ts +4 -1
- package/dist/tool-provider.js +22 -7
- package/dist/types.d.cts +6 -0
- package/dist/types.d.ts +6 -0
- package/dist/write-file-tool.cjs +41 -7
- package/dist/write-file-tool.js +46 -8
- package/dist/write-file-tool.test.cjs +217 -0
- package/dist/write-file-tool.test.d.cts +2 -0
- package/dist/write-file-tool.test.d.ts +2 -0
- package/dist/write-file-tool.test.js +194 -0
- package/package.json +2 -2
package/dist/edit-file-tool.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { createPatch } from "diff";
|
|
4
5
|
import { ApprovalType } from "@dexto/core";
|
|
5
6
|
import { ToolError } from "@dexto/core";
|
|
6
7
|
import { ToolErrorCode } from "@dexto/core";
|
|
7
8
|
import { DextoRuntimeError } from "@dexto/core";
|
|
9
|
+
import { FileSystemErrorCode } from "./error-codes.js";
|
|
10
|
+
const previewContentHashCache = /* @__PURE__ */ new Map();
|
|
11
|
+
function computeContentHash(content) {
|
|
12
|
+
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
13
|
+
}
|
|
8
14
|
const EditFileInputSchema = z.object({
|
|
9
15
|
file_path: z.string().describe("Absolute path to the file to edit"),
|
|
10
16
|
old_string: z.string().describe("Text to replace (must be unique unless replace_all is true)"),
|
|
@@ -36,10 +42,10 @@ function createEditFileTool(options) {
|
|
|
36
42
|
* Check if this edit operation needs directory access approval.
|
|
37
43
|
* Returns custom approval request if the file is outside allowed paths.
|
|
38
44
|
*/
|
|
39
|
-
getApprovalOverride: (args) => {
|
|
45
|
+
getApprovalOverride: async (args) => {
|
|
40
46
|
const { file_path } = args;
|
|
41
47
|
if (!file_path) return null;
|
|
42
|
-
const isAllowed = fileSystemService.isPathWithinConfigAllowed(file_path);
|
|
48
|
+
const isAllowed = await fileSystemService.isPathWithinConfigAllowed(file_path);
|
|
43
49
|
if (isAllowed) {
|
|
44
50
|
return null;
|
|
45
51
|
}
|
|
@@ -75,12 +81,19 @@ function createEditFileTool(options) {
|
|
|
75
81
|
/**
|
|
76
82
|
* Generate preview for approval UI - shows diff without modifying file
|
|
77
83
|
* Throws ToolError.validationFailed() for validation errors (file not found, string not found)
|
|
84
|
+
* Stores content hash for change detection in execute phase.
|
|
78
85
|
*/
|
|
79
|
-
generatePreview: async (input,
|
|
86
|
+
generatePreview: async (input, context) => {
|
|
80
87
|
const { file_path, old_string, new_string, replace_all } = input;
|
|
81
88
|
try {
|
|
82
89
|
const originalFile = await fileSystemService.readFile(file_path);
|
|
83
90
|
const originalContent = originalFile.content;
|
|
91
|
+
if (context?.toolCallId) {
|
|
92
|
+
previewContentHashCache.set(
|
|
93
|
+
context.toolCallId,
|
|
94
|
+
computeContentHash(originalContent)
|
|
95
|
+
);
|
|
96
|
+
}
|
|
84
97
|
if (!replace_all) {
|
|
85
98
|
const occurrences = originalContent.split(old_string).length - 1;
|
|
86
99
|
if (occurrences > 1) {
|
|
@@ -113,25 +126,36 @@ function createEditFileTool(options) {
|
|
|
113
126
|
return null;
|
|
114
127
|
}
|
|
115
128
|
},
|
|
116
|
-
execute: async (input,
|
|
129
|
+
execute: async (input, context) => {
|
|
117
130
|
const { file_path, old_string, new_string, replace_all } = input;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
{
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
131
|
+
if (context?.toolCallId && previewContentHashCache.has(context.toolCallId)) {
|
|
132
|
+
const expectedHash = previewContentHashCache.get(context.toolCallId);
|
|
133
|
+
previewContentHashCache.delete(context.toolCallId);
|
|
134
|
+
let currentContent;
|
|
135
|
+
try {
|
|
136
|
+
const currentFile = await fileSystemService.readFile(file_path);
|
|
137
|
+
currentContent = currentFile.content;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error instanceof DextoRuntimeError && error.code === FileSystemErrorCode.FILE_NOT_FOUND) {
|
|
140
|
+
throw ToolError.fileModifiedSincePreview("edit_file", file_path);
|
|
141
|
+
}
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
const currentHash = computeContentHash(currentContent);
|
|
145
|
+
if (expectedHash !== currentHash) {
|
|
146
|
+
throw ToolError.fileModifiedSincePreview("edit_file", file_path);
|
|
130
147
|
}
|
|
148
|
+
}
|
|
149
|
+
const result = await fileSystemService.editFile(file_path, {
|
|
150
|
+
oldString: old_string,
|
|
151
|
+
newString: new_string,
|
|
152
|
+
replaceAll: replace_all
|
|
153
|
+
});
|
|
154
|
+
const _display = generateDiffPreview(
|
|
155
|
+
file_path,
|
|
156
|
+
result.originalContent,
|
|
157
|
+
result.newContent
|
|
131
158
|
);
|
|
132
|
-
const newFile = await fileSystemService.readFile(file_path);
|
|
133
|
-
const newContent = newFile.content;
|
|
134
|
-
const _display = generateDiffPreview(file_path, originalContent, newContent);
|
|
135
159
|
return {
|
|
136
160
|
success: result.success,
|
|
137
161
|
path: result.path,
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __copyProps = (to, from, except, desc) => {
|
|
9
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
10
|
+
for (let key of __getOwnPropNames(from))
|
|
11
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
12
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
13
|
+
}
|
|
14
|
+
return to;
|
|
15
|
+
};
|
|
16
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
17
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
18
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
19
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
20
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
21
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
22
|
+
mod
|
|
23
|
+
));
|
|
24
|
+
var import_vitest = require("vitest");
|
|
25
|
+
var path = __toESM(require("node:path"), 1);
|
|
26
|
+
var fs = __toESM(require("node:fs/promises"), 1);
|
|
27
|
+
var os = __toESM(require("node:os"), 1);
|
|
28
|
+
var import_edit_file_tool = require("./edit-file-tool.js");
|
|
29
|
+
var import_filesystem_service = require("./filesystem-service.js");
|
|
30
|
+
var import_core = require("@dexto/core");
|
|
31
|
+
var import_core2 = require("@dexto/core");
|
|
32
|
+
const createMockLogger = () => ({
|
|
33
|
+
debug: import_vitest.vi.fn(),
|
|
34
|
+
info: import_vitest.vi.fn(),
|
|
35
|
+
warn: import_vitest.vi.fn(),
|
|
36
|
+
error: import_vitest.vi.fn(),
|
|
37
|
+
createChild: import_vitest.vi.fn().mockReturnThis()
|
|
38
|
+
});
|
|
39
|
+
(0, import_vitest.describe)("edit_file tool", () => {
|
|
40
|
+
let mockLogger;
|
|
41
|
+
let tempDir;
|
|
42
|
+
let fileSystemService;
|
|
43
|
+
(0, import_vitest.beforeEach)(async () => {
|
|
44
|
+
mockLogger = createMockLogger();
|
|
45
|
+
const rawTempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dexto-edit-test-"));
|
|
46
|
+
tempDir = await fs.realpath(rawTempDir);
|
|
47
|
+
fileSystemService = new import_filesystem_service.FileSystemService(
|
|
48
|
+
{
|
|
49
|
+
allowedPaths: [tempDir],
|
|
50
|
+
blockedPaths: [],
|
|
51
|
+
blockedExtensions: [],
|
|
52
|
+
maxFileSize: 10 * 1024 * 1024,
|
|
53
|
+
workingDirectory: tempDir,
|
|
54
|
+
enableBackups: false,
|
|
55
|
+
backupRetentionDays: 7
|
|
56
|
+
},
|
|
57
|
+
mockLogger
|
|
58
|
+
);
|
|
59
|
+
await fileSystemService.initialize();
|
|
60
|
+
import_vitest.vi.clearAllMocks();
|
|
61
|
+
});
|
|
62
|
+
(0, import_vitest.afterEach)(async () => {
|
|
63
|
+
try {
|
|
64
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
65
|
+
} catch {
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
(0, import_vitest.describe)("File Modification Detection", () => {
|
|
69
|
+
(0, import_vitest.it)("should succeed when file is not modified between preview and execute", async () => {
|
|
70
|
+
const tool = (0, import_edit_file_tool.createEditFileTool)({ fileSystemService });
|
|
71
|
+
const testFile = path.join(tempDir, "test.txt");
|
|
72
|
+
await fs.writeFile(testFile, "hello world");
|
|
73
|
+
const toolCallId = "test-call-123";
|
|
74
|
+
const input = {
|
|
75
|
+
file_path: testFile,
|
|
76
|
+
old_string: "world",
|
|
77
|
+
new_string: "universe"
|
|
78
|
+
};
|
|
79
|
+
const preview = await tool.generatePreview(input, { toolCallId });
|
|
80
|
+
(0, import_vitest.expect)(preview).toBeDefined();
|
|
81
|
+
const result = await tool.execute(input, { toolCallId });
|
|
82
|
+
(0, import_vitest.expect)(result.success).toBe(true);
|
|
83
|
+
(0, import_vitest.expect)(result.path).toBe(testFile);
|
|
84
|
+
const content = await fs.readFile(testFile, "utf-8");
|
|
85
|
+
(0, import_vitest.expect)(content).toBe("hello universe");
|
|
86
|
+
});
|
|
87
|
+
(0, import_vitest.it)("should fail when file is modified between preview and execute", async () => {
|
|
88
|
+
const tool = (0, import_edit_file_tool.createEditFileTool)({ fileSystemService });
|
|
89
|
+
const testFile = path.join(tempDir, "test.txt");
|
|
90
|
+
await fs.writeFile(testFile, "hello world");
|
|
91
|
+
const toolCallId = "test-call-456";
|
|
92
|
+
const input = {
|
|
93
|
+
file_path: testFile,
|
|
94
|
+
old_string: "world",
|
|
95
|
+
new_string: "universe"
|
|
96
|
+
};
|
|
97
|
+
const preview = await tool.generatePreview(input, { toolCallId });
|
|
98
|
+
(0, import_vitest.expect)(preview).toBeDefined();
|
|
99
|
+
await fs.writeFile(testFile, "hello world - user added this");
|
|
100
|
+
try {
|
|
101
|
+
await tool.execute(input, { toolCallId });
|
|
102
|
+
import_vitest.expect.fail("Should have thrown an error");
|
|
103
|
+
} catch (error) {
|
|
104
|
+
(0, import_vitest.expect)(error).toBeInstanceOf(import_core2.DextoRuntimeError);
|
|
105
|
+
(0, import_vitest.expect)(error.code).toBe(
|
|
106
|
+
import_core.ToolErrorCode.FILE_MODIFIED_SINCE_PREVIEW
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
const content = await fs.readFile(testFile, "utf-8");
|
|
110
|
+
(0, import_vitest.expect)(content).toBe("hello world - user added this");
|
|
111
|
+
});
|
|
112
|
+
(0, import_vitest.it)("should detect file modification with correct error code", async () => {
|
|
113
|
+
const tool = (0, import_edit_file_tool.createEditFileTool)({ fileSystemService });
|
|
114
|
+
const testFile = path.join(tempDir, "test.txt");
|
|
115
|
+
await fs.writeFile(testFile, "hello world");
|
|
116
|
+
const toolCallId = "test-call-789";
|
|
117
|
+
const input = {
|
|
118
|
+
file_path: testFile,
|
|
119
|
+
old_string: "world",
|
|
120
|
+
new_string: "universe"
|
|
121
|
+
};
|
|
122
|
+
await tool.generatePreview(input, { toolCallId });
|
|
123
|
+
await fs.writeFile(testFile, "hello world modified");
|
|
124
|
+
try {
|
|
125
|
+
await tool.execute(input, { toolCallId });
|
|
126
|
+
import_vitest.expect.fail("Should have thrown an error");
|
|
127
|
+
} catch (error) {
|
|
128
|
+
(0, import_vitest.expect)(error).toBeInstanceOf(import_core2.DextoRuntimeError);
|
|
129
|
+
(0, import_vitest.expect)(error.code).toBe(
|
|
130
|
+
import_core.ToolErrorCode.FILE_MODIFIED_SINCE_PREVIEW
|
|
131
|
+
);
|
|
132
|
+
(0, import_vitest.expect)(error.message).toContain(
|
|
133
|
+
"modified since the preview"
|
|
134
|
+
);
|
|
135
|
+
(0, import_vitest.expect)(error.message).toContain("read the file again");
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
(0, import_vitest.it)("should work without toolCallId (no modification check)", async () => {
|
|
139
|
+
const tool = (0, import_edit_file_tool.createEditFileTool)({ fileSystemService });
|
|
140
|
+
const testFile = path.join(tempDir, "test.txt");
|
|
141
|
+
await fs.writeFile(testFile, "hello world");
|
|
142
|
+
const input = {
|
|
143
|
+
file_path: testFile,
|
|
144
|
+
old_string: "world",
|
|
145
|
+
new_string: "universe"
|
|
146
|
+
};
|
|
147
|
+
await tool.generatePreview(input, {});
|
|
148
|
+
await fs.writeFile(testFile, "hello world changed");
|
|
149
|
+
try {
|
|
150
|
+
await tool.execute(input, {});
|
|
151
|
+
} catch (error) {
|
|
152
|
+
(0, import_vitest.expect)(error).toBeInstanceOf(import_core2.DextoRuntimeError);
|
|
153
|
+
(0, import_vitest.expect)(error.code).not.toBe(
|
|
154
|
+
import_core.ToolErrorCode.FILE_MODIFIED_SINCE_PREVIEW
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
(0, import_vitest.it)("should clean up hash cache after successful execution", async () => {
|
|
159
|
+
const tool = (0, import_edit_file_tool.createEditFileTool)({ fileSystemService });
|
|
160
|
+
const testFile = path.join(tempDir, "test.txt");
|
|
161
|
+
await fs.writeFile(testFile, "hello world");
|
|
162
|
+
const toolCallId = "test-call-cleanup";
|
|
163
|
+
const input = {
|
|
164
|
+
file_path: testFile,
|
|
165
|
+
old_string: "world",
|
|
166
|
+
new_string: "universe"
|
|
167
|
+
};
|
|
168
|
+
await tool.generatePreview(input, { toolCallId });
|
|
169
|
+
await tool.execute(input, { toolCallId });
|
|
170
|
+
const input2 = {
|
|
171
|
+
file_path: testFile,
|
|
172
|
+
old_string: "universe",
|
|
173
|
+
new_string: "galaxy"
|
|
174
|
+
};
|
|
175
|
+
await tool.generatePreview(input2, { toolCallId });
|
|
176
|
+
const result = await tool.execute(input2, { toolCallId });
|
|
177
|
+
(0, import_vitest.expect)(result.success).toBe(true);
|
|
178
|
+
const content = await fs.readFile(testFile, "utf-8");
|
|
179
|
+
(0, import_vitest.expect)(content).toBe("hello galaxy");
|
|
180
|
+
});
|
|
181
|
+
(0, import_vitest.it)("should clean up hash cache after failed execution", async () => {
|
|
182
|
+
const tool = (0, import_edit_file_tool.createEditFileTool)({ fileSystemService });
|
|
183
|
+
const testFile = path.join(tempDir, "test.txt");
|
|
184
|
+
await fs.writeFile(testFile, "hello world");
|
|
185
|
+
const toolCallId = "test-call-fail-cleanup";
|
|
186
|
+
const input = {
|
|
187
|
+
file_path: testFile,
|
|
188
|
+
old_string: "world",
|
|
189
|
+
new_string: "universe"
|
|
190
|
+
};
|
|
191
|
+
await tool.generatePreview(input, { toolCallId });
|
|
192
|
+
await fs.writeFile(testFile, "hello world modified");
|
|
193
|
+
try {
|
|
194
|
+
await tool.execute(input, { toolCallId });
|
|
195
|
+
} catch {
|
|
196
|
+
}
|
|
197
|
+
await fs.writeFile(testFile, "hello world");
|
|
198
|
+
await tool.generatePreview(input, { toolCallId });
|
|
199
|
+
const result = await tool.execute(input, { toolCallId });
|
|
200
|
+
(0, import_vitest.expect)(result.success).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as fs from "node:fs/promises";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import { createEditFileTool } from "./edit-file-tool.js";
|
|
6
|
+
import { FileSystemService } from "./filesystem-service.js";
|
|
7
|
+
import { ToolErrorCode } from "@dexto/core";
|
|
8
|
+
import { DextoRuntimeError } from "@dexto/core";
|
|
9
|
+
const createMockLogger = () => ({
|
|
10
|
+
debug: vi.fn(),
|
|
11
|
+
info: vi.fn(),
|
|
12
|
+
warn: vi.fn(),
|
|
13
|
+
error: vi.fn(),
|
|
14
|
+
createChild: vi.fn().mockReturnThis()
|
|
15
|
+
});
|
|
16
|
+
describe("edit_file tool", () => {
|
|
17
|
+
let mockLogger;
|
|
18
|
+
let tempDir;
|
|
19
|
+
let fileSystemService;
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
mockLogger = createMockLogger();
|
|
22
|
+
const rawTempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dexto-edit-test-"));
|
|
23
|
+
tempDir = await fs.realpath(rawTempDir);
|
|
24
|
+
fileSystemService = new FileSystemService(
|
|
25
|
+
{
|
|
26
|
+
allowedPaths: [tempDir],
|
|
27
|
+
blockedPaths: [],
|
|
28
|
+
blockedExtensions: [],
|
|
29
|
+
maxFileSize: 10 * 1024 * 1024,
|
|
30
|
+
workingDirectory: tempDir,
|
|
31
|
+
enableBackups: false,
|
|
32
|
+
backupRetentionDays: 7
|
|
33
|
+
},
|
|
34
|
+
mockLogger
|
|
35
|
+
);
|
|
36
|
+
await fileSystemService.initialize();
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
});
|
|
39
|
+
afterEach(async () => {
|
|
40
|
+
try {
|
|
41
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
42
|
+
} catch {
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
describe("File Modification Detection", () => {
|
|
46
|
+
it("should succeed when file is not modified between preview and execute", async () => {
|
|
47
|
+
const tool = createEditFileTool({ fileSystemService });
|
|
48
|
+
const testFile = path.join(tempDir, "test.txt");
|
|
49
|
+
await fs.writeFile(testFile, "hello world");
|
|
50
|
+
const toolCallId = "test-call-123";
|
|
51
|
+
const input = {
|
|
52
|
+
file_path: testFile,
|
|
53
|
+
old_string: "world",
|
|
54
|
+
new_string: "universe"
|
|
55
|
+
};
|
|
56
|
+
const preview = await tool.generatePreview(input, { toolCallId });
|
|
57
|
+
expect(preview).toBeDefined();
|
|
58
|
+
const result = await tool.execute(input, { toolCallId });
|
|
59
|
+
expect(result.success).toBe(true);
|
|
60
|
+
expect(result.path).toBe(testFile);
|
|
61
|
+
const content = await fs.readFile(testFile, "utf-8");
|
|
62
|
+
expect(content).toBe("hello universe");
|
|
63
|
+
});
|
|
64
|
+
it("should fail when file is modified between preview and execute", async () => {
|
|
65
|
+
const tool = createEditFileTool({ fileSystemService });
|
|
66
|
+
const testFile = path.join(tempDir, "test.txt");
|
|
67
|
+
await fs.writeFile(testFile, "hello world");
|
|
68
|
+
const toolCallId = "test-call-456";
|
|
69
|
+
const input = {
|
|
70
|
+
file_path: testFile,
|
|
71
|
+
old_string: "world",
|
|
72
|
+
new_string: "universe"
|
|
73
|
+
};
|
|
74
|
+
const preview = await tool.generatePreview(input, { toolCallId });
|
|
75
|
+
expect(preview).toBeDefined();
|
|
76
|
+
await fs.writeFile(testFile, "hello world - user added this");
|
|
77
|
+
try {
|
|
78
|
+
await tool.execute(input, { toolCallId });
|
|
79
|
+
expect.fail("Should have thrown an error");
|
|
80
|
+
} catch (error) {
|
|
81
|
+
expect(error).toBeInstanceOf(DextoRuntimeError);
|
|
82
|
+
expect(error.code).toBe(
|
|
83
|
+
ToolErrorCode.FILE_MODIFIED_SINCE_PREVIEW
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
const content = await fs.readFile(testFile, "utf-8");
|
|
87
|
+
expect(content).toBe("hello world - user added this");
|
|
88
|
+
});
|
|
89
|
+
it("should detect file modification with correct error code", async () => {
|
|
90
|
+
const tool = createEditFileTool({ fileSystemService });
|
|
91
|
+
const testFile = path.join(tempDir, "test.txt");
|
|
92
|
+
await fs.writeFile(testFile, "hello world");
|
|
93
|
+
const toolCallId = "test-call-789";
|
|
94
|
+
const input = {
|
|
95
|
+
file_path: testFile,
|
|
96
|
+
old_string: "world",
|
|
97
|
+
new_string: "universe"
|
|
98
|
+
};
|
|
99
|
+
await tool.generatePreview(input, { toolCallId });
|
|
100
|
+
await fs.writeFile(testFile, "hello world modified");
|
|
101
|
+
try {
|
|
102
|
+
await tool.execute(input, { toolCallId });
|
|
103
|
+
expect.fail("Should have thrown an error");
|
|
104
|
+
} catch (error) {
|
|
105
|
+
expect(error).toBeInstanceOf(DextoRuntimeError);
|
|
106
|
+
expect(error.code).toBe(
|
|
107
|
+
ToolErrorCode.FILE_MODIFIED_SINCE_PREVIEW
|
|
108
|
+
);
|
|
109
|
+
expect(error.message).toContain(
|
|
110
|
+
"modified since the preview"
|
|
111
|
+
);
|
|
112
|
+
expect(error.message).toContain("read the file again");
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
it("should work without toolCallId (no modification check)", async () => {
|
|
116
|
+
const tool = createEditFileTool({ fileSystemService });
|
|
117
|
+
const testFile = path.join(tempDir, "test.txt");
|
|
118
|
+
await fs.writeFile(testFile, "hello world");
|
|
119
|
+
const input = {
|
|
120
|
+
file_path: testFile,
|
|
121
|
+
old_string: "world",
|
|
122
|
+
new_string: "universe"
|
|
123
|
+
};
|
|
124
|
+
await tool.generatePreview(input, {});
|
|
125
|
+
await fs.writeFile(testFile, "hello world changed");
|
|
126
|
+
try {
|
|
127
|
+
await tool.execute(input, {});
|
|
128
|
+
} catch (error) {
|
|
129
|
+
expect(error).toBeInstanceOf(DextoRuntimeError);
|
|
130
|
+
expect(error.code).not.toBe(
|
|
131
|
+
ToolErrorCode.FILE_MODIFIED_SINCE_PREVIEW
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
it("should clean up hash cache after successful execution", async () => {
|
|
136
|
+
const tool = createEditFileTool({ fileSystemService });
|
|
137
|
+
const testFile = path.join(tempDir, "test.txt");
|
|
138
|
+
await fs.writeFile(testFile, "hello world");
|
|
139
|
+
const toolCallId = "test-call-cleanup";
|
|
140
|
+
const input = {
|
|
141
|
+
file_path: testFile,
|
|
142
|
+
old_string: "world",
|
|
143
|
+
new_string: "universe"
|
|
144
|
+
};
|
|
145
|
+
await tool.generatePreview(input, { toolCallId });
|
|
146
|
+
await tool.execute(input, { toolCallId });
|
|
147
|
+
const input2 = {
|
|
148
|
+
file_path: testFile,
|
|
149
|
+
old_string: "universe",
|
|
150
|
+
new_string: "galaxy"
|
|
151
|
+
};
|
|
152
|
+
await tool.generatePreview(input2, { toolCallId });
|
|
153
|
+
const result = await tool.execute(input2, { toolCallId });
|
|
154
|
+
expect(result.success).toBe(true);
|
|
155
|
+
const content = await fs.readFile(testFile, "utf-8");
|
|
156
|
+
expect(content).toBe("hello galaxy");
|
|
157
|
+
});
|
|
158
|
+
it("should clean up hash cache after failed execution", async () => {
|
|
159
|
+
const tool = createEditFileTool({ fileSystemService });
|
|
160
|
+
const testFile = path.join(tempDir, "test.txt");
|
|
161
|
+
await fs.writeFile(testFile, "hello world");
|
|
162
|
+
const toolCallId = "test-call-fail-cleanup";
|
|
163
|
+
const input = {
|
|
164
|
+
file_path: testFile,
|
|
165
|
+
old_string: "world",
|
|
166
|
+
new_string: "universe"
|
|
167
|
+
};
|
|
168
|
+
await tool.generatePreview(input, { toolCallId });
|
|
169
|
+
await fs.writeFile(testFile, "hello world modified");
|
|
170
|
+
try {
|
|
171
|
+
await tool.execute(input, { toolCallId });
|
|
172
|
+
} catch {
|
|
173
|
+
}
|
|
174
|
+
await fs.writeFile(testFile, "hello world");
|
|
175
|
+
await tool.generatePreview(input, { toolCallId });
|
|
176
|
+
const result = await tool.execute(input, { toolCallId });
|
|
177
|
+
expect(result.success).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -126,7 +126,7 @@ class FileSystemService {
|
|
|
126
126
|
* @param filePath The file path to check (can be relative or absolute)
|
|
127
127
|
* @returns true if the path is within config-allowed paths, false otherwise
|
|
128
128
|
*/
|
|
129
|
-
isPathWithinConfigAllowed(filePath) {
|
|
129
|
+
async isPathWithinConfigAllowed(filePath) {
|
|
130
130
|
return this.pathValidator.isPathWithinAllowed(filePath);
|
|
131
131
|
}
|
|
132
132
|
/**
|
|
@@ -134,7 +134,7 @@ class FileSystemService {
|
|
|
134
134
|
*/
|
|
135
135
|
async readFile(filePath, options = {}) {
|
|
136
136
|
await this.ensureInitialized();
|
|
137
|
-
const validation = this.pathValidator.validatePath(filePath);
|
|
137
|
+
const validation = await this.pathValidator.validatePath(filePath);
|
|
138
138
|
if (!validation.isValid || !validation.normalizedPath) {
|
|
139
139
|
throw import_errors.FileSystemError.invalidPath(filePath, validation.error || "Unknown error");
|
|
140
140
|
}
|
|
@@ -211,7 +211,7 @@ class FileSystemService {
|
|
|
211
211
|
});
|
|
212
212
|
const validFiles = [];
|
|
213
213
|
for (const file of files) {
|
|
214
|
-
const validation = this.pathValidator.validatePath(file);
|
|
214
|
+
const validation = await this.pathValidator.validatePath(file);
|
|
215
215
|
if (!validation.isValid || !validation.normalizedPath) {
|
|
216
216
|
this.logger.debug(`Skipping invalid path: ${file}`);
|
|
217
217
|
continue;
|
|
@@ -344,7 +344,7 @@ class FileSystemService {
|
|
|
344
344
|
*/
|
|
345
345
|
async writeFile(filePath, content, options = {}) {
|
|
346
346
|
await this.ensureInitialized();
|
|
347
|
-
const validation = this.pathValidator.validatePath(filePath);
|
|
347
|
+
const validation = await this.pathValidator.validatePath(filePath);
|
|
348
348
|
if (!validation.isValid || !validation.normalizedPath) {
|
|
349
349
|
throw import_errors.FileSystemError.invalidPath(filePath, validation.error || "Unknown error");
|
|
350
350
|
}
|
|
@@ -386,14 +386,14 @@ class FileSystemService {
|
|
|
386
386
|
*/
|
|
387
387
|
async editFile(filePath, operation, options = {}) {
|
|
388
388
|
await this.ensureInitialized();
|
|
389
|
-
const validation = this.pathValidator.validatePath(filePath);
|
|
389
|
+
const validation = await this.pathValidator.validatePath(filePath);
|
|
390
390
|
if (!validation.isValid || !validation.normalizedPath) {
|
|
391
391
|
throw import_errors.FileSystemError.invalidPath(filePath, validation.error || "Unknown error");
|
|
392
392
|
}
|
|
393
393
|
const normalizedPath = validation.normalizedPath;
|
|
394
394
|
const fileContent = await this.readFile(normalizedPath);
|
|
395
|
-
|
|
396
|
-
const occurrences = (
|
|
395
|
+
const originalContent = fileContent.content;
|
|
396
|
+
const occurrences = (originalContent.match(
|
|
397
397
|
new RegExp(operation.oldString.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")
|
|
398
398
|
) || []).length;
|
|
399
399
|
if (occurrences === 0) {
|
|
@@ -407,21 +407,24 @@ class FileSystemService {
|
|
|
407
407
|
backupPath = await this.createBackup(normalizedPath);
|
|
408
408
|
}
|
|
409
409
|
try {
|
|
410
|
+
let newContent;
|
|
410
411
|
if (operation.replaceAll) {
|
|
411
|
-
|
|
412
|
+
newContent = originalContent.replace(
|
|
412
413
|
new RegExp(operation.oldString.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"),
|
|
413
414
|
operation.newString
|
|
414
415
|
);
|
|
415
416
|
} else {
|
|
416
|
-
|
|
417
|
+
newContent = originalContent.replace(operation.oldString, operation.newString);
|
|
417
418
|
}
|
|
418
|
-
await fs.writeFile(normalizedPath,
|
|
419
|
+
await fs.writeFile(normalizedPath, newContent, options.encoding || DEFAULT_ENCODING);
|
|
419
420
|
this.logger.debug(`File edited: ${normalizedPath} (${occurrences} replacements)`);
|
|
420
421
|
return {
|
|
421
422
|
success: true,
|
|
422
423
|
path: normalizedPath,
|
|
423
424
|
changesCount: occurrences,
|
|
424
|
-
backupPath
|
|
425
|
+
backupPath,
|
|
426
|
+
originalContent,
|
|
427
|
+
newContent
|
|
425
428
|
};
|
|
426
429
|
} catch (error) {
|
|
427
430
|
throw import_errors.FileSystemError.editFailed(
|
|
@@ -513,10 +516,10 @@ class FileSystemService {
|
|
|
513
516
|
return { ...this.config };
|
|
514
517
|
}
|
|
515
518
|
/**
|
|
516
|
-
* Check if a path is allowed
|
|
519
|
+
* Check if a path is allowed (async for non-blocking symlink resolution)
|
|
517
520
|
*/
|
|
518
|
-
isPathAllowed(filePath) {
|
|
519
|
-
const validation = this.pathValidator.validatePath(filePath);
|
|
521
|
+
async isPathAllowed(filePath) {
|
|
522
|
+
const validation = await this.pathValidator.validatePath(filePath);
|
|
520
523
|
return validation.isValid;
|
|
521
524
|
}
|
|
522
525
|
}
|
|
@@ -65,7 +65,7 @@ declare class FileSystemService {
|
|
|
65
65
|
* @param filePath The file path to check (can be relative or absolute)
|
|
66
66
|
* @returns true if the path is within config-allowed paths, false otherwise
|
|
67
67
|
*/
|
|
68
|
-
isPathWithinConfigAllowed(filePath: string): boolean
|
|
68
|
+
isPathWithinConfigAllowed(filePath: string): Promise<boolean>;
|
|
69
69
|
/**
|
|
70
70
|
* Read a file with validation and size limits
|
|
71
71
|
*/
|
|
@@ -99,9 +99,9 @@ declare class FileSystemService {
|
|
|
99
99
|
*/
|
|
100
100
|
getConfig(): Readonly<FileSystemConfig>;
|
|
101
101
|
/**
|
|
102
|
-
* Check if a path is allowed
|
|
102
|
+
* Check if a path is allowed (async for non-blocking symlink resolution)
|
|
103
103
|
*/
|
|
104
|
-
isPathAllowed(filePath: string): boolean
|
|
104
|
+
isPathAllowed(filePath: string): Promise<boolean>;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
export { FileSystemService };
|
|
@@ -65,7 +65,7 @@ declare class FileSystemService {
|
|
|
65
65
|
* @param filePath The file path to check (can be relative or absolute)
|
|
66
66
|
* @returns true if the path is within config-allowed paths, false otherwise
|
|
67
67
|
*/
|
|
68
|
-
isPathWithinConfigAllowed(filePath: string): boolean
|
|
68
|
+
isPathWithinConfigAllowed(filePath: string): Promise<boolean>;
|
|
69
69
|
/**
|
|
70
70
|
* Read a file with validation and size limits
|
|
71
71
|
*/
|
|
@@ -99,9 +99,9 @@ declare class FileSystemService {
|
|
|
99
99
|
*/
|
|
100
100
|
getConfig(): Readonly<FileSystemConfig>;
|
|
101
101
|
/**
|
|
102
|
-
* Check if a path is allowed
|
|
102
|
+
* Check if a path is allowed (async for non-blocking symlink resolution)
|
|
103
103
|
*/
|
|
104
|
-
isPathAllowed(filePath: string): boolean
|
|
104
|
+
isPathAllowed(filePath: string): Promise<boolean>;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
export { FileSystemService };
|