@dexto/tools-filesystem 1.5.0
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/LICENSE +44 -0
- package/dist/directory-approval.integration.test.cjs +467 -0
- package/dist/directory-approval.integration.test.d.cts +2 -0
- package/dist/directory-approval.integration.test.d.ts +2 -0
- package/dist/directory-approval.integration.test.js +444 -0
- package/dist/edit-file-tool.cjs +181 -0
- package/dist/edit-file-tool.d.cts +17 -0
- package/dist/edit-file-tool.d.ts +17 -0
- package/dist/edit-file-tool.js +147 -0
- package/dist/error-codes.cjs +53 -0
- package/dist/error-codes.d.cts +32 -0
- package/dist/error-codes.d.ts +32 -0
- package/dist/error-codes.js +29 -0
- package/dist/errors.cjs +302 -0
- package/dist/errors.d.cts +112 -0
- package/dist/errors.d.ts +112 -0
- package/dist/errors.js +278 -0
- package/dist/file-tool-types.cjs +16 -0
- package/dist/file-tool-types.d.cts +46 -0
- package/dist/file-tool-types.d.ts +46 -0
- package/dist/file-tool-types.js +0 -0
- package/dist/filesystem-service.cjs +526 -0
- package/dist/filesystem-service.d.cts +107 -0
- package/dist/filesystem-service.d.ts +107 -0
- package/dist/filesystem-service.js +492 -0
- package/dist/glob-files-tool.cjs +70 -0
- package/dist/glob-files-tool.d.cts +16 -0
- package/dist/glob-files-tool.d.ts +16 -0
- package/dist/glob-files-tool.js +46 -0
- package/dist/grep-content-tool.cjs +86 -0
- package/dist/grep-content-tool.d.cts +16 -0
- package/dist/grep-content-tool.d.ts +16 -0
- package/dist/grep-content-tool.js +62 -0
- package/dist/index.cjs +55 -0
- package/dist/index.d.cts +14 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +22 -0
- package/dist/path-validator.cjs +232 -0
- package/dist/path-validator.d.cts +90 -0
- package/dist/path-validator.d.ts +90 -0
- package/dist/path-validator.js +198 -0
- package/dist/path-validator.test.cjs +444 -0
- package/dist/path-validator.test.d.cts +2 -0
- package/dist/path-validator.test.d.ts +2 -0
- package/dist/path-validator.test.js +443 -0
- package/dist/read-file-tool.cjs +117 -0
- package/dist/read-file-tool.d.cts +17 -0
- package/dist/read-file-tool.d.ts +17 -0
- package/dist/read-file-tool.js +83 -0
- package/dist/tool-provider.cjs +108 -0
- package/dist/tool-provider.d.cts +74 -0
- package/dist/tool-provider.d.ts +74 -0
- package/dist/tool-provider.js +84 -0
- package/dist/types.cjs +16 -0
- package/dist/types.d.cts +172 -0
- package/dist/types.d.ts +172 -0
- package/dist/types.js +0 -0
- package/dist/write-file-tool.cjs +177 -0
- package/dist/write-file-tool.d.cts +17 -0
- package/dist/write-file-tool.d.ts +17 -0
- package/dist/write-file-tool.js +143 -0
- package/package.json +42 -0
|
@@ -0,0 +1,444 @@
|
|
|
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 { createReadFileTool } from "./read-file-tool.js";
|
|
6
|
+
import { createWriteFileTool } from "./write-file-tool.js";
|
|
7
|
+
import { createEditFileTool } from "./edit-file-tool.js";
|
|
8
|
+
import { FileSystemService } from "./filesystem-service.js";
|
|
9
|
+
import { ApprovalType, ApprovalStatus } from "@dexto/core";
|
|
10
|
+
const createMockLogger = () => ({
|
|
11
|
+
debug: vi.fn(),
|
|
12
|
+
info: vi.fn(),
|
|
13
|
+
warn: vi.fn(),
|
|
14
|
+
error: vi.fn(),
|
|
15
|
+
createChild: vi.fn().mockReturnThis()
|
|
16
|
+
});
|
|
17
|
+
describe("Directory Approval Integration Tests", () => {
|
|
18
|
+
let mockLogger;
|
|
19
|
+
let tempDir;
|
|
20
|
+
let fileSystemService;
|
|
21
|
+
let directoryApproval;
|
|
22
|
+
let isSessionApprovedMock;
|
|
23
|
+
let addApprovedMock;
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
mockLogger = createMockLogger();
|
|
26
|
+
const rawTempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dexto-fs-test-"));
|
|
27
|
+
tempDir = await fs.realpath(rawTempDir);
|
|
28
|
+
fileSystemService = new FileSystemService(
|
|
29
|
+
{
|
|
30
|
+
allowedPaths: [tempDir],
|
|
31
|
+
blockedPaths: [],
|
|
32
|
+
blockedExtensions: [],
|
|
33
|
+
maxFileSize: 10 * 1024 * 1024,
|
|
34
|
+
workingDirectory: tempDir,
|
|
35
|
+
enableBackups: false,
|
|
36
|
+
backupRetentionDays: 7
|
|
37
|
+
},
|
|
38
|
+
mockLogger
|
|
39
|
+
);
|
|
40
|
+
await fileSystemService.initialize();
|
|
41
|
+
isSessionApprovedMock = vi.fn().mockReturnValue(false);
|
|
42
|
+
addApprovedMock = vi.fn();
|
|
43
|
+
directoryApproval = {
|
|
44
|
+
isSessionApproved: isSessionApprovedMock,
|
|
45
|
+
addApproved: addApprovedMock
|
|
46
|
+
};
|
|
47
|
+
vi.clearAllMocks();
|
|
48
|
+
});
|
|
49
|
+
afterEach(async () => {
|
|
50
|
+
try {
|
|
51
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
52
|
+
} catch {
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
describe("Read File Tool", () => {
|
|
56
|
+
describe("getApprovalOverride", () => {
|
|
57
|
+
it("should return null for paths within working directory (no prompt needed)", async () => {
|
|
58
|
+
const tool = createReadFileTool({
|
|
59
|
+
fileSystemService,
|
|
60
|
+
directoryApproval
|
|
61
|
+
});
|
|
62
|
+
const testFile = path.join(tempDir, "test.txt");
|
|
63
|
+
await fs.writeFile(testFile, "test content");
|
|
64
|
+
const override = tool.getApprovalOverride?.({ file_path: testFile });
|
|
65
|
+
expect(override).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
it("should return directory access approval for external paths", async () => {
|
|
68
|
+
const tool = createReadFileTool({
|
|
69
|
+
fileSystemService,
|
|
70
|
+
directoryApproval
|
|
71
|
+
});
|
|
72
|
+
const externalPath = "/external/project/file.ts";
|
|
73
|
+
const override = tool.getApprovalOverride?.({ file_path: externalPath });
|
|
74
|
+
expect(override).not.toBeNull();
|
|
75
|
+
expect(override?.type).toBe(ApprovalType.DIRECTORY_ACCESS);
|
|
76
|
+
const metadata = override?.metadata;
|
|
77
|
+
expect(metadata?.path).toBe(path.resolve(externalPath));
|
|
78
|
+
expect(metadata?.parentDir).toBe(path.dirname(path.resolve(externalPath)));
|
|
79
|
+
expect(metadata?.operation).toBe("read");
|
|
80
|
+
expect(metadata?.toolName).toBe("read_file");
|
|
81
|
+
});
|
|
82
|
+
it("should return null when external path is session-approved", async () => {
|
|
83
|
+
isSessionApprovedMock.mockReturnValue(true);
|
|
84
|
+
const tool = createReadFileTool({
|
|
85
|
+
fileSystemService,
|
|
86
|
+
directoryApproval
|
|
87
|
+
});
|
|
88
|
+
const externalPath = "/external/project/file.ts";
|
|
89
|
+
const override = tool.getApprovalOverride?.({ file_path: externalPath });
|
|
90
|
+
expect(override).toBeNull();
|
|
91
|
+
expect(isSessionApprovedMock).toHaveBeenCalledWith(externalPath);
|
|
92
|
+
});
|
|
93
|
+
it("should return null when file_path is missing", async () => {
|
|
94
|
+
const tool = createReadFileTool({
|
|
95
|
+
fileSystemService,
|
|
96
|
+
directoryApproval
|
|
97
|
+
});
|
|
98
|
+
const override = tool.getApprovalOverride?.({});
|
|
99
|
+
expect(override).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
describe("onApprovalGranted", () => {
|
|
103
|
+
it("should add directory as session-approved when rememberDirectory is true", async () => {
|
|
104
|
+
const tool = createReadFileTool({
|
|
105
|
+
fileSystemService,
|
|
106
|
+
directoryApproval
|
|
107
|
+
});
|
|
108
|
+
const externalPath = "/external/project/file.ts";
|
|
109
|
+
tool.getApprovalOverride?.({ file_path: externalPath });
|
|
110
|
+
tool.onApprovalGranted?.({
|
|
111
|
+
approvalId: "test-approval",
|
|
112
|
+
status: ApprovalStatus.APPROVED,
|
|
113
|
+
data: { rememberDirectory: true }
|
|
114
|
+
});
|
|
115
|
+
expect(addApprovedMock).toHaveBeenCalledWith(
|
|
116
|
+
path.dirname(path.resolve(externalPath)),
|
|
117
|
+
"session"
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
it("should add directory as once-approved when rememberDirectory is false", async () => {
|
|
121
|
+
const tool = createReadFileTool({
|
|
122
|
+
fileSystemService,
|
|
123
|
+
directoryApproval
|
|
124
|
+
});
|
|
125
|
+
const externalPath = "/external/project/file.ts";
|
|
126
|
+
tool.getApprovalOverride?.({ file_path: externalPath });
|
|
127
|
+
tool.onApprovalGranted?.({
|
|
128
|
+
approvalId: "test-approval",
|
|
129
|
+
status: ApprovalStatus.APPROVED,
|
|
130
|
+
data: { rememberDirectory: false }
|
|
131
|
+
});
|
|
132
|
+
expect(addApprovedMock).toHaveBeenCalledWith(
|
|
133
|
+
path.dirname(path.resolve(externalPath)),
|
|
134
|
+
"once"
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
it("should default to once-approved when rememberDirectory is not specified", async () => {
|
|
138
|
+
const tool = createReadFileTool({
|
|
139
|
+
fileSystemService,
|
|
140
|
+
directoryApproval
|
|
141
|
+
});
|
|
142
|
+
const externalPath = "/external/project/file.ts";
|
|
143
|
+
tool.getApprovalOverride?.({ file_path: externalPath });
|
|
144
|
+
tool.onApprovalGranted?.({
|
|
145
|
+
approvalId: "test-approval",
|
|
146
|
+
status: ApprovalStatus.APPROVED,
|
|
147
|
+
data: {}
|
|
148
|
+
});
|
|
149
|
+
expect(addApprovedMock).toHaveBeenCalledWith(
|
|
150
|
+
path.dirname(path.resolve(externalPath)),
|
|
151
|
+
"once"
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
it("should not call addApproved when directoryApproval is not provided", async () => {
|
|
155
|
+
const tool = createReadFileTool({
|
|
156
|
+
fileSystemService,
|
|
157
|
+
directoryApproval: void 0
|
|
158
|
+
});
|
|
159
|
+
const externalPath = "/external/project/file.ts";
|
|
160
|
+
tool.getApprovalOverride?.({ file_path: externalPath });
|
|
161
|
+
tool.onApprovalGranted?.({
|
|
162
|
+
approvalId: "test-approval",
|
|
163
|
+
status: ApprovalStatus.APPROVED,
|
|
164
|
+
data: { rememberDirectory: true }
|
|
165
|
+
});
|
|
166
|
+
expect(addApprovedMock).not.toHaveBeenCalled();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
describe("execute", () => {
|
|
170
|
+
it("should read file contents within working directory", async () => {
|
|
171
|
+
const tool = createReadFileTool({
|
|
172
|
+
fileSystemService,
|
|
173
|
+
directoryApproval
|
|
174
|
+
});
|
|
175
|
+
const testFile = path.join(tempDir, "readable.txt");
|
|
176
|
+
await fs.writeFile(testFile, "Hello, world!\nLine 2");
|
|
177
|
+
const result = await tool.execute({ file_path: testFile }, {});
|
|
178
|
+
expect(result.content).toBe("Hello, world!\nLine 2");
|
|
179
|
+
expect(result.lines).toBe(2);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
describe("Write File Tool", () => {
|
|
184
|
+
describe("getApprovalOverride", () => {
|
|
185
|
+
it("should return null for paths within working directory", async () => {
|
|
186
|
+
const tool = createWriteFileTool({
|
|
187
|
+
fileSystemService,
|
|
188
|
+
directoryApproval
|
|
189
|
+
});
|
|
190
|
+
const testFile = path.join(tempDir, "new-file.txt");
|
|
191
|
+
const override = tool.getApprovalOverride?.({
|
|
192
|
+
file_path: testFile,
|
|
193
|
+
content: "test"
|
|
194
|
+
});
|
|
195
|
+
expect(override).toBeNull();
|
|
196
|
+
});
|
|
197
|
+
it("should return directory access approval for external paths", async () => {
|
|
198
|
+
const tool = createWriteFileTool({
|
|
199
|
+
fileSystemService,
|
|
200
|
+
directoryApproval
|
|
201
|
+
});
|
|
202
|
+
const externalPath = "/external/project/new.ts";
|
|
203
|
+
const override = tool.getApprovalOverride?.({
|
|
204
|
+
file_path: externalPath,
|
|
205
|
+
content: "test"
|
|
206
|
+
});
|
|
207
|
+
expect(override).not.toBeNull();
|
|
208
|
+
expect(override?.type).toBe(ApprovalType.DIRECTORY_ACCESS);
|
|
209
|
+
const metadata = override?.metadata;
|
|
210
|
+
expect(metadata?.operation).toBe("write");
|
|
211
|
+
expect(metadata?.toolName).toBe("write_file");
|
|
212
|
+
});
|
|
213
|
+
it("should return null when external path is session-approved", async () => {
|
|
214
|
+
isSessionApprovedMock.mockReturnValue(true);
|
|
215
|
+
const tool = createWriteFileTool({
|
|
216
|
+
fileSystemService,
|
|
217
|
+
directoryApproval
|
|
218
|
+
});
|
|
219
|
+
const externalPath = "/external/project/new.ts";
|
|
220
|
+
const override = tool.getApprovalOverride?.({
|
|
221
|
+
file_path: externalPath,
|
|
222
|
+
content: "test"
|
|
223
|
+
});
|
|
224
|
+
expect(override).toBeNull();
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
describe("onApprovalGranted", () => {
|
|
228
|
+
it("should add directory as session-approved when rememberDirectory is true", async () => {
|
|
229
|
+
const tool = createWriteFileTool({
|
|
230
|
+
fileSystemService,
|
|
231
|
+
directoryApproval
|
|
232
|
+
});
|
|
233
|
+
const externalPath = "/external/project/new.ts";
|
|
234
|
+
tool.getApprovalOverride?.({ file_path: externalPath, content: "test" });
|
|
235
|
+
tool.onApprovalGranted?.({
|
|
236
|
+
approvalId: "test-approval",
|
|
237
|
+
status: ApprovalStatus.APPROVED,
|
|
238
|
+
data: { rememberDirectory: true }
|
|
239
|
+
});
|
|
240
|
+
expect(addApprovedMock).toHaveBeenCalledWith(
|
|
241
|
+
path.dirname(path.resolve(externalPath)),
|
|
242
|
+
"session"
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
describe("Edit File Tool", () => {
|
|
248
|
+
describe("getApprovalOverride", () => {
|
|
249
|
+
it("should return null for paths within working directory", async () => {
|
|
250
|
+
const tool = createEditFileTool({
|
|
251
|
+
fileSystemService,
|
|
252
|
+
directoryApproval
|
|
253
|
+
});
|
|
254
|
+
const testFile = path.join(tempDir, "existing.txt");
|
|
255
|
+
const override = tool.getApprovalOverride?.({
|
|
256
|
+
file_path: testFile,
|
|
257
|
+
old_string: "old",
|
|
258
|
+
new_string: "new"
|
|
259
|
+
});
|
|
260
|
+
expect(override).toBeNull();
|
|
261
|
+
});
|
|
262
|
+
it("should return directory access approval for external paths", async () => {
|
|
263
|
+
const tool = createEditFileTool({
|
|
264
|
+
fileSystemService,
|
|
265
|
+
directoryApproval
|
|
266
|
+
});
|
|
267
|
+
const externalPath = "/external/project/existing.ts";
|
|
268
|
+
const override = tool.getApprovalOverride?.({
|
|
269
|
+
file_path: externalPath,
|
|
270
|
+
old_string: "old",
|
|
271
|
+
new_string: "new"
|
|
272
|
+
});
|
|
273
|
+
expect(override).not.toBeNull();
|
|
274
|
+
expect(override?.type).toBe(ApprovalType.DIRECTORY_ACCESS);
|
|
275
|
+
const metadata = override?.metadata;
|
|
276
|
+
expect(metadata?.operation).toBe("edit");
|
|
277
|
+
expect(metadata?.toolName).toBe("edit_file");
|
|
278
|
+
});
|
|
279
|
+
it("should return null when external path is session-approved", async () => {
|
|
280
|
+
isSessionApprovedMock.mockReturnValue(true);
|
|
281
|
+
const tool = createEditFileTool({
|
|
282
|
+
fileSystemService,
|
|
283
|
+
directoryApproval
|
|
284
|
+
});
|
|
285
|
+
const externalPath = "/external/project/existing.ts";
|
|
286
|
+
const override = tool.getApprovalOverride?.({
|
|
287
|
+
file_path: externalPath,
|
|
288
|
+
old_string: "old",
|
|
289
|
+
new_string: "new"
|
|
290
|
+
});
|
|
291
|
+
expect(override).toBeNull();
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
describe("Session vs Once Approval Scenarios", () => {
|
|
296
|
+
it("should not prompt for subsequent requests after session approval", async () => {
|
|
297
|
+
const tool = createReadFileTool({
|
|
298
|
+
fileSystemService,
|
|
299
|
+
directoryApproval
|
|
300
|
+
});
|
|
301
|
+
const externalPath1 = "/external/project/file1.ts";
|
|
302
|
+
const externalPath2 = "/external/project/file2.ts";
|
|
303
|
+
let override = tool.getApprovalOverride?.({ file_path: externalPath1 });
|
|
304
|
+
expect(override).not.toBeNull();
|
|
305
|
+
tool.onApprovalGranted?.({
|
|
306
|
+
approvalId: "approval-1",
|
|
307
|
+
status: ApprovalStatus.APPROVED,
|
|
308
|
+
data: { rememberDirectory: true }
|
|
309
|
+
});
|
|
310
|
+
expect(addApprovedMock).toHaveBeenCalledWith(
|
|
311
|
+
path.dirname(path.resolve(externalPath1)),
|
|
312
|
+
"session"
|
|
313
|
+
);
|
|
314
|
+
isSessionApprovedMock.mockReturnValue(true);
|
|
315
|
+
override = tool.getApprovalOverride?.({ file_path: externalPath2 });
|
|
316
|
+
expect(override).toBeNull();
|
|
317
|
+
});
|
|
318
|
+
it("should prompt for subsequent requests after once approval", async () => {
|
|
319
|
+
const tool = createReadFileTool({
|
|
320
|
+
fileSystemService,
|
|
321
|
+
directoryApproval
|
|
322
|
+
});
|
|
323
|
+
const externalPath1 = "/external/project/file1.ts";
|
|
324
|
+
const externalPath2 = "/external/project/file2.ts";
|
|
325
|
+
let override = tool.getApprovalOverride?.({ file_path: externalPath1 });
|
|
326
|
+
expect(override).not.toBeNull();
|
|
327
|
+
tool.onApprovalGranted?.({
|
|
328
|
+
approvalId: "approval-1",
|
|
329
|
+
status: ApprovalStatus.APPROVED,
|
|
330
|
+
data: { rememberDirectory: false }
|
|
331
|
+
});
|
|
332
|
+
expect(addApprovedMock).toHaveBeenCalledWith(
|
|
333
|
+
path.dirname(path.resolve(externalPath1)),
|
|
334
|
+
"once"
|
|
335
|
+
);
|
|
336
|
+
isSessionApprovedMock.mockReturnValue(false);
|
|
337
|
+
override = tool.getApprovalOverride?.({ file_path: externalPath2 });
|
|
338
|
+
expect(override).not.toBeNull();
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
describe("Path Containment Scenarios", () => {
|
|
342
|
+
it("should cover child paths when parent directory is session-approved", async () => {
|
|
343
|
+
const tool = createReadFileTool({
|
|
344
|
+
fileSystemService,
|
|
345
|
+
directoryApproval
|
|
346
|
+
});
|
|
347
|
+
isSessionApprovedMock.mockImplementation((filePath) => {
|
|
348
|
+
const normalizedPath = path.resolve(filePath);
|
|
349
|
+
const approvedDir = "/external/project";
|
|
350
|
+
return normalizedPath.startsWith(approvedDir + path.sep) || normalizedPath === approvedDir;
|
|
351
|
+
});
|
|
352
|
+
let override = tool.getApprovalOverride?.({ file_path: "/external/project/file.ts" });
|
|
353
|
+
expect(override).toBeNull();
|
|
354
|
+
override = tool.getApprovalOverride?.({
|
|
355
|
+
file_path: "/external/project/deep/nested/file.ts"
|
|
356
|
+
});
|
|
357
|
+
expect(override).toBeNull();
|
|
358
|
+
});
|
|
359
|
+
it("should NOT cover sibling directories", async () => {
|
|
360
|
+
const tool = createReadFileTool({
|
|
361
|
+
fileSystemService,
|
|
362
|
+
directoryApproval
|
|
363
|
+
});
|
|
364
|
+
isSessionApprovedMock.mockImplementation((filePath) => {
|
|
365
|
+
const normalizedPath = path.resolve(filePath);
|
|
366
|
+
const approvedDir = "/external/sub";
|
|
367
|
+
return normalizedPath.startsWith(approvedDir + path.sep) || normalizedPath === approvedDir;
|
|
368
|
+
});
|
|
369
|
+
let override = tool.getApprovalOverride?.({ file_path: "/external/sub/file.ts" });
|
|
370
|
+
expect(override).toBeNull();
|
|
371
|
+
override = tool.getApprovalOverride?.({ file_path: "/external/other/file.ts" });
|
|
372
|
+
expect(override).not.toBeNull();
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
describe("Different External Directories Scenarios", () => {
|
|
376
|
+
it("should require separate approval for different external directories", async () => {
|
|
377
|
+
const tool = createReadFileTool({
|
|
378
|
+
fileSystemService,
|
|
379
|
+
directoryApproval
|
|
380
|
+
});
|
|
381
|
+
const dir1Path = "/external/project1/file.ts";
|
|
382
|
+
const dir2Path = "/external/project2/file.ts";
|
|
383
|
+
const override1 = tool.getApprovalOverride?.({ file_path: dir1Path });
|
|
384
|
+
expect(override1).not.toBeNull();
|
|
385
|
+
const metadata1 = override1?.metadata;
|
|
386
|
+
expect(metadata1?.parentDir).toBe("/external/project1");
|
|
387
|
+
const override2 = tool.getApprovalOverride?.({ file_path: dir2Path });
|
|
388
|
+
expect(override2).not.toBeNull();
|
|
389
|
+
const metadata2 = override2?.metadata;
|
|
390
|
+
expect(metadata2?.parentDir).toBe("/external/project2");
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
describe("Mixed Operations Scenarios", () => {
|
|
394
|
+
it("should share directory approval across different file operations", async () => {
|
|
395
|
+
const readTool = createReadFileTool({ fileSystemService, directoryApproval });
|
|
396
|
+
const writeTool = createWriteFileTool({ fileSystemService, directoryApproval });
|
|
397
|
+
const editTool = createEditFileTool({ fileSystemService, directoryApproval });
|
|
398
|
+
const externalDir = "/external/project";
|
|
399
|
+
expect(
|
|
400
|
+
readTool.getApprovalOverride?.({ file_path: `${externalDir}/file1.ts` })
|
|
401
|
+
).not.toBeNull();
|
|
402
|
+
expect(
|
|
403
|
+
writeTool.getApprovalOverride?.({
|
|
404
|
+
file_path: `${externalDir}/file2.ts`,
|
|
405
|
+
content: "test"
|
|
406
|
+
})
|
|
407
|
+
).not.toBeNull();
|
|
408
|
+
expect(
|
|
409
|
+
editTool.getApprovalOverride?.({
|
|
410
|
+
file_path: `${externalDir}/file3.ts`,
|
|
411
|
+
old_string: "a",
|
|
412
|
+
new_string: "b"
|
|
413
|
+
})
|
|
414
|
+
).not.toBeNull();
|
|
415
|
+
isSessionApprovedMock.mockReturnValue(true);
|
|
416
|
+
expect(
|
|
417
|
+
readTool.getApprovalOverride?.({ file_path: `${externalDir}/file1.ts` })
|
|
418
|
+
).toBeNull();
|
|
419
|
+
expect(
|
|
420
|
+
writeTool.getApprovalOverride?.({
|
|
421
|
+
file_path: `${externalDir}/file2.ts`,
|
|
422
|
+
content: "test"
|
|
423
|
+
})
|
|
424
|
+
).toBeNull();
|
|
425
|
+
expect(
|
|
426
|
+
editTool.getApprovalOverride?.({
|
|
427
|
+
file_path: `${externalDir}/file3.ts`,
|
|
428
|
+
old_string: "a",
|
|
429
|
+
new_string: "b"
|
|
430
|
+
})
|
|
431
|
+
).toBeNull();
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
describe("Without Directory Approval Callbacks", () => {
|
|
435
|
+
it("should work without directory approval callbacks (all paths need normal tool confirmation)", async () => {
|
|
436
|
+
const tool = createReadFileTool({
|
|
437
|
+
fileSystemService,
|
|
438
|
+
directoryApproval: void 0
|
|
439
|
+
});
|
|
440
|
+
const override = tool.getApprovalOverride?.({ file_path: "/external/project/file.ts" });
|
|
441
|
+
expect(override).not.toBeNull();
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
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 __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
var edit_file_tool_exports = {};
|
|
30
|
+
__export(edit_file_tool_exports, {
|
|
31
|
+
createEditFileTool: () => createEditFileTool
|
|
32
|
+
});
|
|
33
|
+
module.exports = __toCommonJS(edit_file_tool_exports);
|
|
34
|
+
var path = __toESM(require("node:path"), 1);
|
|
35
|
+
var import_zod = require("zod");
|
|
36
|
+
var import_diff = require("diff");
|
|
37
|
+
var import_core = require("@dexto/core");
|
|
38
|
+
var import_core2 = require("@dexto/core");
|
|
39
|
+
var import_core3 = require("@dexto/core");
|
|
40
|
+
var import_core4 = require("@dexto/core");
|
|
41
|
+
const EditFileInputSchema = import_zod.z.object({
|
|
42
|
+
file_path: import_zod.z.string().describe("Absolute path to the file to edit"),
|
|
43
|
+
old_string: import_zod.z.string().describe("Text to replace (must be unique unless replace_all is true)"),
|
|
44
|
+
new_string: import_zod.z.string().describe("Replacement text"),
|
|
45
|
+
replace_all: import_zod.z.boolean().optional().default(false).describe("Replace all occurrences (default: false, requires unique match)")
|
|
46
|
+
}).strict();
|
|
47
|
+
function generateDiffPreview(filePath, originalContent, newContent) {
|
|
48
|
+
const unified = (0, import_diff.createPatch)(filePath, originalContent, newContent, "before", "after", {
|
|
49
|
+
context: 3
|
|
50
|
+
});
|
|
51
|
+
const additions = (unified.match(/^\+[^+]/gm) || []).length;
|
|
52
|
+
const deletions = (unified.match(/^-[^-]/gm) || []).length;
|
|
53
|
+
return {
|
|
54
|
+
type: "diff",
|
|
55
|
+
unified,
|
|
56
|
+
filename: filePath,
|
|
57
|
+
additions,
|
|
58
|
+
deletions
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function createEditFileTool(options) {
|
|
62
|
+
const { fileSystemService, directoryApproval } = options;
|
|
63
|
+
let pendingApprovalParentDir;
|
|
64
|
+
return {
|
|
65
|
+
id: "edit_file",
|
|
66
|
+
description: "Edit a file by replacing text. By default, old_string must be unique in the file (will error if found multiple times). Set replace_all=true to replace all occurrences. Automatically creates backup before editing. Requires approval. Returns success status, path, number of changes made, and backup path.",
|
|
67
|
+
inputSchema: EditFileInputSchema,
|
|
68
|
+
/**
|
|
69
|
+
* Check if this edit operation needs directory access approval.
|
|
70
|
+
* Returns custom approval request if the file is outside allowed paths.
|
|
71
|
+
*/
|
|
72
|
+
getApprovalOverride: (args) => {
|
|
73
|
+
const { file_path } = args;
|
|
74
|
+
if (!file_path) return null;
|
|
75
|
+
const isAllowed = fileSystemService.isPathWithinConfigAllowed(file_path);
|
|
76
|
+
if (isAllowed) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
if (directoryApproval?.isSessionApproved(file_path)) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const absolutePath = path.resolve(file_path);
|
|
83
|
+
const parentDir = path.dirname(absolutePath);
|
|
84
|
+
pendingApprovalParentDir = parentDir;
|
|
85
|
+
return {
|
|
86
|
+
type: import_core.ApprovalType.DIRECTORY_ACCESS,
|
|
87
|
+
metadata: {
|
|
88
|
+
path: absolutePath,
|
|
89
|
+
parentDir,
|
|
90
|
+
operation: "edit",
|
|
91
|
+
toolName: "edit_file"
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
/**
|
|
96
|
+
* Handle approved directory access - remember the directory for session
|
|
97
|
+
*/
|
|
98
|
+
onApprovalGranted: (response) => {
|
|
99
|
+
if (!directoryApproval || !pendingApprovalParentDir) return;
|
|
100
|
+
const data = response.data;
|
|
101
|
+
const rememberDirectory = data?.rememberDirectory ?? false;
|
|
102
|
+
directoryApproval.addApproved(
|
|
103
|
+
pendingApprovalParentDir,
|
|
104
|
+
rememberDirectory ? "session" : "once"
|
|
105
|
+
);
|
|
106
|
+
pendingApprovalParentDir = void 0;
|
|
107
|
+
},
|
|
108
|
+
/**
|
|
109
|
+
* Generate preview for approval UI - shows diff without modifying file
|
|
110
|
+
* Throws ToolError.validationFailed() for validation errors (file not found, string not found)
|
|
111
|
+
*/
|
|
112
|
+
generatePreview: async (input, _context) => {
|
|
113
|
+
const { file_path, old_string, new_string, replace_all } = input;
|
|
114
|
+
try {
|
|
115
|
+
const originalFile = await fileSystemService.readFile(file_path);
|
|
116
|
+
const originalContent = originalFile.content;
|
|
117
|
+
if (!replace_all) {
|
|
118
|
+
const occurrences = originalContent.split(old_string).length - 1;
|
|
119
|
+
if (occurrences > 1) {
|
|
120
|
+
throw import_core2.ToolError.validationFailed(
|
|
121
|
+
"edit_file",
|
|
122
|
+
`String found ${occurrences} times in file. Set replace_all=true to replace all, or provide more context to make old_string unique.`,
|
|
123
|
+
{ file_path, occurrences }
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const newContent = replace_all ? originalContent.split(old_string).join(new_string) : originalContent.replace(old_string, new_string);
|
|
128
|
+
if (originalContent === newContent) {
|
|
129
|
+
throw import_core2.ToolError.validationFailed(
|
|
130
|
+
"edit_file",
|
|
131
|
+
`String not found in file: "${old_string.slice(0, 50)}${old_string.length > 50 ? "..." : ""}"`,
|
|
132
|
+
{ file_path, old_string_preview: old_string.slice(0, 100) }
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return generateDiffPreview(file_path, originalContent, newContent);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (error instanceof import_core4.DextoRuntimeError && error.code === import_core3.ToolErrorCode.VALIDATION_FAILED) {
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
if (error instanceof import_core4.DextoRuntimeError) {
|
|
141
|
+
throw import_core2.ToolError.validationFailed("edit_file", error.message, {
|
|
142
|
+
file_path,
|
|
143
|
+
originalErrorCode: error.code
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
execute: async (input, _context) => {
|
|
150
|
+
const { file_path, old_string, new_string, replace_all } = input;
|
|
151
|
+
const originalFile = await fileSystemService.readFile(file_path);
|
|
152
|
+
const originalContent = originalFile.content;
|
|
153
|
+
const result = await fileSystemService.editFile(
|
|
154
|
+
file_path,
|
|
155
|
+
{
|
|
156
|
+
oldString: old_string,
|
|
157
|
+
newString: new_string,
|
|
158
|
+
replaceAll: replace_all
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
backup: true
|
|
162
|
+
// Always create backup for internal tools
|
|
163
|
+
}
|
|
164
|
+
);
|
|
165
|
+
const newFile = await fileSystemService.readFile(file_path);
|
|
166
|
+
const newContent = newFile.content;
|
|
167
|
+
const _display = generateDiffPreview(file_path, originalContent, newContent);
|
|
168
|
+
return {
|
|
169
|
+
success: result.success,
|
|
170
|
+
path: result.path,
|
|
171
|
+
changes_count: result.changesCount,
|
|
172
|
+
...result.backupPath && { backup_path: result.backupPath },
|
|
173
|
+
_display
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
179
|
+
0 && (module.exports = {
|
|
180
|
+
createEditFileTool
|
|
181
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { InternalTool } from '@dexto/core';
|
|
2
|
+
import { FileToolOptions } from './file-tool-types.cjs';
|
|
3
|
+
import './filesystem-service.cjs';
|
|
4
|
+
import './types.cjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Edit File Tool
|
|
8
|
+
*
|
|
9
|
+
* Internal tool for editing files by replacing text (requires approval)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create the edit_file internal tool with directory approval support
|
|
14
|
+
*/
|
|
15
|
+
declare function createEditFileTool(options: FileToolOptions): InternalTool;
|
|
16
|
+
|
|
17
|
+
export { createEditFileTool };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { InternalTool } from '@dexto/core';
|
|
2
|
+
import { FileToolOptions } from './file-tool-types.js';
|
|
3
|
+
import './filesystem-service.js';
|
|
4
|
+
import './types.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Edit File Tool
|
|
8
|
+
*
|
|
9
|
+
* Internal tool for editing files by replacing text (requires approval)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create the edit_file internal tool with directory approval support
|
|
14
|
+
*/
|
|
15
|
+
declare function createEditFileTool(options: FileToolOptions): InternalTool;
|
|
16
|
+
|
|
17
|
+
export { createEditFileTool };
|