@dexto/tools-filesystem 1.5.2 → 1.5.4
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 +24 -14
- package/dist/filesystem-service.d.cts +8 -3
- package/dist/filesystem-service.d.ts +8 -3
- package/dist/filesystem-service.js +24 -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/glob-files-tool.cjs +56 -3
- package/dist/glob-files-tool.d.cts +4 -3
- package/dist/glob-files-tool.d.ts +4 -3
- package/dist/glob-files-tool.js +46 -3
- package/dist/grep-content-tool.cjs +55 -3
- package/dist/grep-content-tool.d.cts +4 -3
- package/dist/grep-content-tool.d.ts +4 -3
- package/dist/grep-content-tool.js +45 -3
- 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
|
+
});
|
|
@@ -66,6 +66,13 @@ class FileSystemService {
|
|
|
66
66
|
getBackupDir() {
|
|
67
67
|
return this.config.backupPath || (0, import_core.getDextoPath)("backups");
|
|
68
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Get the effective working directory for file operations.
|
|
71
|
+
* Falls back to process.cwd() if not configured.
|
|
72
|
+
*/
|
|
73
|
+
getWorkingDirectory() {
|
|
74
|
+
return this.config.workingDirectory || process.cwd();
|
|
75
|
+
}
|
|
69
76
|
/**
|
|
70
77
|
* Initialize the service.
|
|
71
78
|
* Safe to call multiple times - subsequent calls return the same promise.
|
|
@@ -126,7 +133,7 @@ class FileSystemService {
|
|
|
126
133
|
* @param filePath The file path to check (can be relative or absolute)
|
|
127
134
|
* @returns true if the path is within config-allowed paths, false otherwise
|
|
128
135
|
*/
|
|
129
|
-
isPathWithinConfigAllowed(filePath) {
|
|
136
|
+
async isPathWithinConfigAllowed(filePath) {
|
|
130
137
|
return this.pathValidator.isPathWithinAllowed(filePath);
|
|
131
138
|
}
|
|
132
139
|
/**
|
|
@@ -134,7 +141,7 @@ class FileSystemService {
|
|
|
134
141
|
*/
|
|
135
142
|
async readFile(filePath, options = {}) {
|
|
136
143
|
await this.ensureInitialized();
|
|
137
|
-
const validation = this.pathValidator.validatePath(filePath);
|
|
144
|
+
const validation = await this.pathValidator.validatePath(filePath);
|
|
138
145
|
if (!validation.isValid || !validation.normalizedPath) {
|
|
139
146
|
throw import_errors.FileSystemError.invalidPath(filePath, validation.error || "Unknown error");
|
|
140
147
|
}
|
|
@@ -211,7 +218,7 @@ class FileSystemService {
|
|
|
211
218
|
});
|
|
212
219
|
const validFiles = [];
|
|
213
220
|
for (const file of files) {
|
|
214
|
-
const validation = this.pathValidator.validatePath(file);
|
|
221
|
+
const validation = await this.pathValidator.validatePath(file);
|
|
215
222
|
if (!validation.isValid || !validation.normalizedPath) {
|
|
216
223
|
this.logger.debug(`Skipping invalid path: ${file}`);
|
|
217
224
|
continue;
|
|
@@ -344,7 +351,7 @@ class FileSystemService {
|
|
|
344
351
|
*/
|
|
345
352
|
async writeFile(filePath, content, options = {}) {
|
|
346
353
|
await this.ensureInitialized();
|
|
347
|
-
const validation = this.pathValidator.validatePath(filePath);
|
|
354
|
+
const validation = await this.pathValidator.validatePath(filePath);
|
|
348
355
|
if (!validation.isValid || !validation.normalizedPath) {
|
|
349
356
|
throw import_errors.FileSystemError.invalidPath(filePath, validation.error || "Unknown error");
|
|
350
357
|
}
|
|
@@ -386,14 +393,14 @@ class FileSystemService {
|
|
|
386
393
|
*/
|
|
387
394
|
async editFile(filePath, operation, options = {}) {
|
|
388
395
|
await this.ensureInitialized();
|
|
389
|
-
const validation = this.pathValidator.validatePath(filePath);
|
|
396
|
+
const validation = await this.pathValidator.validatePath(filePath);
|
|
390
397
|
if (!validation.isValid || !validation.normalizedPath) {
|
|
391
398
|
throw import_errors.FileSystemError.invalidPath(filePath, validation.error || "Unknown error");
|
|
392
399
|
}
|
|
393
400
|
const normalizedPath = validation.normalizedPath;
|
|
394
401
|
const fileContent = await this.readFile(normalizedPath);
|
|
395
|
-
|
|
396
|
-
const occurrences = (
|
|
402
|
+
const originalContent = fileContent.content;
|
|
403
|
+
const occurrences = (originalContent.match(
|
|
397
404
|
new RegExp(operation.oldString.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")
|
|
398
405
|
) || []).length;
|
|
399
406
|
if (occurrences === 0) {
|
|
@@ -407,21 +414,24 @@ class FileSystemService {
|
|
|
407
414
|
backupPath = await this.createBackup(normalizedPath);
|
|
408
415
|
}
|
|
409
416
|
try {
|
|
417
|
+
let newContent;
|
|
410
418
|
if (operation.replaceAll) {
|
|
411
|
-
|
|
419
|
+
newContent = originalContent.replace(
|
|
412
420
|
new RegExp(operation.oldString.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"),
|
|
413
421
|
operation.newString
|
|
414
422
|
);
|
|
415
423
|
} else {
|
|
416
|
-
|
|
424
|
+
newContent = originalContent.replace(operation.oldString, operation.newString);
|
|
417
425
|
}
|
|
418
|
-
await fs.writeFile(normalizedPath,
|
|
426
|
+
await fs.writeFile(normalizedPath, newContent, options.encoding || DEFAULT_ENCODING);
|
|
419
427
|
this.logger.debug(`File edited: ${normalizedPath} (${occurrences} replacements)`);
|
|
420
428
|
return {
|
|
421
429
|
success: true,
|
|
422
430
|
path: normalizedPath,
|
|
423
431
|
changesCount: occurrences,
|
|
424
|
-
backupPath
|
|
432
|
+
backupPath,
|
|
433
|
+
originalContent,
|
|
434
|
+
newContent
|
|
425
435
|
};
|
|
426
436
|
} catch (error) {
|
|
427
437
|
throw import_errors.FileSystemError.editFailed(
|
|
@@ -513,10 +523,10 @@ class FileSystemService {
|
|
|
513
523
|
return { ...this.config };
|
|
514
524
|
}
|
|
515
525
|
/**
|
|
516
|
-
* Check if a path is allowed
|
|
526
|
+
* Check if a path is allowed (async for non-blocking symlink resolution)
|
|
517
527
|
*/
|
|
518
|
-
isPathAllowed(filePath) {
|
|
519
|
-
const validation = this.pathValidator.validatePath(filePath);
|
|
528
|
+
async isPathAllowed(filePath) {
|
|
529
|
+
const validation = await this.pathValidator.validatePath(filePath);
|
|
520
530
|
return validation.isValid;
|
|
521
531
|
}
|
|
522
532
|
}
|
|
@@ -36,6 +36,11 @@ declare class FileSystemService {
|
|
|
36
36
|
* TODO: Migrate to explicit configuration via CLI enrichment layer (per-agent paths)
|
|
37
37
|
*/
|
|
38
38
|
private getBackupDir;
|
|
39
|
+
/**
|
|
40
|
+
* Get the effective working directory for file operations.
|
|
41
|
+
* Falls back to process.cwd() if not configured.
|
|
42
|
+
*/
|
|
43
|
+
getWorkingDirectory(): string;
|
|
39
44
|
/**
|
|
40
45
|
* Initialize the service.
|
|
41
46
|
* Safe to call multiple times - subsequent calls return the same promise.
|
|
@@ -65,7 +70,7 @@ declare class FileSystemService {
|
|
|
65
70
|
* @param filePath The file path to check (can be relative or absolute)
|
|
66
71
|
* @returns true if the path is within config-allowed paths, false otherwise
|
|
67
72
|
*/
|
|
68
|
-
isPathWithinConfigAllowed(filePath: string): boolean
|
|
73
|
+
isPathWithinConfigAllowed(filePath: string): Promise<boolean>;
|
|
69
74
|
/**
|
|
70
75
|
* Read a file with validation and size limits
|
|
71
76
|
*/
|
|
@@ -99,9 +104,9 @@ declare class FileSystemService {
|
|
|
99
104
|
*/
|
|
100
105
|
getConfig(): Readonly<FileSystemConfig>;
|
|
101
106
|
/**
|
|
102
|
-
* Check if a path is allowed
|
|
107
|
+
* Check if a path is allowed (async for non-blocking symlink resolution)
|
|
103
108
|
*/
|
|
104
|
-
isPathAllowed(filePath: string): boolean
|
|
109
|
+
isPathAllowed(filePath: string): Promise<boolean>;
|
|
105
110
|
}
|
|
106
111
|
|
|
107
112
|
export { FileSystemService };
|
|
@@ -36,6 +36,11 @@ declare class FileSystemService {
|
|
|
36
36
|
* TODO: Migrate to explicit configuration via CLI enrichment layer (per-agent paths)
|
|
37
37
|
*/
|
|
38
38
|
private getBackupDir;
|
|
39
|
+
/**
|
|
40
|
+
* Get the effective working directory for file operations.
|
|
41
|
+
* Falls back to process.cwd() if not configured.
|
|
42
|
+
*/
|
|
43
|
+
getWorkingDirectory(): string;
|
|
39
44
|
/**
|
|
40
45
|
* Initialize the service.
|
|
41
46
|
* Safe to call multiple times - subsequent calls return the same promise.
|
|
@@ -65,7 +70,7 @@ declare class FileSystemService {
|
|
|
65
70
|
* @param filePath The file path to check (can be relative or absolute)
|
|
66
71
|
* @returns true if the path is within config-allowed paths, false otherwise
|
|
67
72
|
*/
|
|
68
|
-
isPathWithinConfigAllowed(filePath: string): boolean
|
|
73
|
+
isPathWithinConfigAllowed(filePath: string): Promise<boolean>;
|
|
69
74
|
/**
|
|
70
75
|
* Read a file with validation and size limits
|
|
71
76
|
*/
|
|
@@ -99,9 +104,9 @@ declare class FileSystemService {
|
|
|
99
104
|
*/
|
|
100
105
|
getConfig(): Readonly<FileSystemConfig>;
|
|
101
106
|
/**
|
|
102
|
-
* Check if a path is allowed
|
|
107
|
+
* Check if a path is allowed (async for non-blocking symlink resolution)
|
|
103
108
|
*/
|
|
104
|
-
isPathAllowed(filePath: string): boolean
|
|
109
|
+
isPathAllowed(filePath: string): Promise<boolean>;
|
|
105
110
|
}
|
|
106
111
|
|
|
107
112
|
export { FileSystemService };
|