@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.
Files changed (45) hide show
  1. package/dist/directory-approval.integration.test.cjs +36 -32
  2. package/dist/directory-approval.integration.test.js +36 -32
  3. package/dist/edit-file-tool.cjs +43 -19
  4. package/dist/edit-file-tool.js +43 -19
  5. package/dist/edit-file-tool.test.cjs +203 -0
  6. package/dist/edit-file-tool.test.d.cts +2 -0
  7. package/dist/edit-file-tool.test.d.ts +2 -0
  8. package/dist/edit-file-tool.test.js +180 -0
  9. package/dist/filesystem-service.cjs +24 -14
  10. package/dist/filesystem-service.d.cts +8 -3
  11. package/dist/filesystem-service.d.ts +8 -3
  12. package/dist/filesystem-service.js +24 -14
  13. package/dist/filesystem-service.test.cjs +233 -0
  14. package/dist/filesystem-service.test.d.cts +2 -0
  15. package/dist/filesystem-service.test.d.ts +2 -0
  16. package/dist/filesystem-service.test.js +210 -0
  17. package/dist/glob-files-tool.cjs +56 -3
  18. package/dist/glob-files-tool.d.cts +4 -3
  19. package/dist/glob-files-tool.d.ts +4 -3
  20. package/dist/glob-files-tool.js +46 -3
  21. package/dist/grep-content-tool.cjs +55 -3
  22. package/dist/grep-content-tool.d.cts +4 -3
  23. package/dist/grep-content-tool.d.ts +4 -3
  24. package/dist/grep-content-tool.js +45 -3
  25. package/dist/path-validator.cjs +29 -20
  26. package/dist/path-validator.d.cts +9 -2
  27. package/dist/path-validator.d.ts +9 -2
  28. package/dist/path-validator.js +29 -20
  29. package/dist/path-validator.test.cjs +54 -48
  30. package/dist/path-validator.test.js +54 -48
  31. package/dist/read-file-tool.cjs +2 -2
  32. package/dist/read-file-tool.js +2 -2
  33. package/dist/tool-provider.cjs +22 -7
  34. package/dist/tool-provider.d.cts +4 -1
  35. package/dist/tool-provider.d.ts +4 -1
  36. package/dist/tool-provider.js +22 -7
  37. package/dist/types.d.cts +6 -0
  38. package/dist/types.d.ts +6 -0
  39. package/dist/write-file-tool.cjs +41 -7
  40. package/dist/write-file-tool.js +46 -8
  41. package/dist/write-file-tool.test.cjs +217 -0
  42. package/dist/write-file-tool.test.d.cts +2 -0
  43. package/dist/write-file-tool.test.d.ts +2 -0
  44. package/dist/write-file-tool.test.js +194 -0
  45. package/package.json +2 -2
@@ -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, _context) => {
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, _context) => {
129
+ execute: async (input, context) => {
117
130
  const { file_path, old_string, new_string, replace_all } = input;
118
- const originalFile = await fileSystemService.readFile(file_path);
119
- const originalContent = originalFile.content;
120
- const result = await fileSystemService.editFile(
121
- file_path,
122
- {
123
- oldString: old_string,
124
- newString: new_string,
125
- replaceAll: replace_all
126
- },
127
- {
128
- backup: true
129
- // Always create backup for internal tools
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,2 @@
1
+
2
+ export { }
@@ -0,0 +1,2 @@
1
+
2
+ export { }
@@ -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
- let content = fileContent.content;
396
- const occurrences = (content.match(
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
- content = content.replace(
419
+ newContent = originalContent.replace(
412
420
  new RegExp(operation.oldString.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"),
413
421
  operation.newString
414
422
  );
415
423
  } else {
416
- content = content.replace(operation.oldString, operation.newString);
424
+ newContent = originalContent.replace(operation.oldString, operation.newString);
417
425
  }
418
- await fs.writeFile(normalizedPath, content, options.encoding || DEFAULT_ENCODING);
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 };