@dexto/tools-filesystem 1.7.1 → 1.8.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.
Files changed (66) hide show
  1. package/dist/directory-approval.cjs +5 -4
  2. package/dist/directory-approval.d.ts +2 -1
  3. package/dist/directory-approval.d.ts.map +1 -1
  4. package/dist/directory-approval.integration.test.cjs +73 -33
  5. package/dist/directory-approval.integration.test.js +73 -33
  6. package/dist/directory-approval.js +2 -1
  7. package/dist/edit-file-tool.cjs +52 -37
  8. package/dist/edit-file-tool.d.ts +1 -1
  9. package/dist/edit-file-tool.d.ts.map +1 -1
  10. package/dist/edit-file-tool.js +43 -29
  11. package/dist/edit-file-tool.test.cjs +159 -2
  12. package/dist/edit-file-tool.test.js +159 -2
  13. package/dist/errors.cjs +53 -53
  14. package/dist/errors.d.ts +1 -1
  15. package/dist/errors.d.ts.map +1 -1
  16. package/dist/errors.js +1 -1
  17. package/dist/file-tool-types.d.ts +1 -1
  18. package/dist/file-tool-types.d.ts.map +1 -1
  19. package/dist/filesystem-service.cjs +60 -60
  20. package/dist/filesystem-service.d.ts +1 -1
  21. package/dist/filesystem-service.d.ts.map +1 -1
  22. package/dist/filesystem-service.js +5 -5
  23. package/dist/filesystem-service.test.cjs +1 -3
  24. package/dist/filesystem-service.test.js +1 -3
  25. package/dist/glob-files-tool.cjs +27 -24
  26. package/dist/glob-files-tool.d.ts +1 -1
  27. package/dist/glob-files-tool.d.ts.map +1 -1
  28. package/dist/glob-files-tool.js +24 -21
  29. package/dist/glob-files-tool.test.cjs +100 -88
  30. package/dist/glob-files-tool.test.js +101 -67
  31. package/dist/grep-content-tool.cjs +129 -44
  32. package/dist/grep-content-tool.d.ts +1 -1
  33. package/dist/grep-content-tool.d.ts.map +1 -1
  34. package/dist/grep-content-tool.js +120 -41
  35. package/dist/grep-content-tool.test.cjs +122 -87
  36. package/dist/grep-content-tool.test.js +123 -66
  37. package/dist/index.d.cts +3 -4
  38. package/dist/path-validator.d.ts +1 -1
  39. package/dist/path-validator.d.ts.map +1 -1
  40. package/dist/read-file-tool.cjs +43 -14
  41. package/dist/read-file-tool.d.ts +1 -1
  42. package/dist/read-file-tool.d.ts.map +1 -1
  43. package/dist/read-file-tool.js +40 -11
  44. package/dist/read-file-tool.test.cjs +119 -0
  45. package/dist/read-file-tool.test.d.ts +2 -0
  46. package/dist/read-file-tool.test.d.ts.map +1 -0
  47. package/dist/read-file-tool.test.js +96 -0
  48. package/dist/read-media-file-tool.cjs +4 -4
  49. package/dist/read-media-file-tool.d.ts +1 -1
  50. package/dist/read-media-file-tool.d.ts.map +1 -1
  51. package/dist/read-media-file-tool.js +1 -1
  52. package/dist/tool-factory.cjs +2 -2
  53. package/dist/tool-factory.js +1 -1
  54. package/dist/types.d.ts +0 -2
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/workspace-paths.cjs +87 -0
  57. package/dist/workspace-paths.d.ts +4 -0
  58. package/dist/workspace-paths.d.ts.map +1 -0
  59. package/dist/workspace-paths.js +51 -0
  60. package/dist/write-file-tool.cjs +74 -34
  61. package/dist/write-file-tool.d.ts +1 -2
  62. package/dist/write-file-tool.d.ts.map +1 -1
  63. package/dist/write-file-tool.js +68 -29
  64. package/dist/write-file-tool.test.cjs +262 -11
  65. package/dist/write-file-tool.test.js +262 -11
  66. package/package.json +3 -3
@@ -29,6 +29,7 @@ var import_write_file_tool = require("./write-file-tool.js");
29
29
  var import_filesystem_service = require("./filesystem-service.js");
30
30
  var import_core = require("@dexto/core");
31
31
  var import_core2 = require("@dexto/core");
32
+ var import_workspace = require("@dexto/core/workspace");
32
33
  const createMockLogger = () => {
33
34
  const logger = {
34
35
  debug: import_vitest.vi.fn(),
@@ -46,8 +47,68 @@ const createMockLogger = () => {
46
47
  };
47
48
  return logger;
48
49
  };
49
- function createToolContext(logger, overrides = {}) {
50
- return { logger, ...overrides };
50
+ function createToolContext(logger, overrides = {}, workspaceRoot = currentWorkspaceRoot) {
51
+ return {
52
+ logger,
53
+ services: createWorkspaceServices(workspaceRoot),
54
+ ...overrides
55
+ };
56
+ }
57
+ let currentWorkspaceRoot = process.cwd();
58
+ function createWorkspaceServices(workspaceRoot, overrides = {}) {
59
+ const workspaceManager = {
60
+ open: import_vitest.vi.fn(async () => ({
61
+ context: {
62
+ id: "test-workspace",
63
+ path: workspaceRoot,
64
+ createdAt: Date.now(),
65
+ lastActiveAt: Date.now()
66
+ },
67
+ capabilities: ["files"],
68
+ files: {
69
+ readFile: async (filePath) => readWorkspaceFile(workspaceRoot, filePath),
70
+ readText: async (filePath) => readWorkspaceFile(workspaceRoot, filePath),
71
+ glob: import_vitest.vi.fn(async () => []),
72
+ writeFile: async (filePath, content) => {
73
+ const resolvedPath = resolveWorkspacePath(workspaceRoot, filePath);
74
+ await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
75
+ try {
76
+ await fs.writeFile(resolvedPath, content, "utf-8");
77
+ } catch (error) {
78
+ if (error.code === "ENOENT") {
79
+ throw import_workspace.WorkspaceError.fileNotFound(filePath);
80
+ }
81
+ throw error;
82
+ }
83
+ },
84
+ listFiles: import_vitest.vi.fn(async () => [])
85
+ }
86
+ })),
87
+ ...overrides
88
+ };
89
+ return {
90
+ approval: {},
91
+ search: {},
92
+ resources: {},
93
+ prompts: {},
94
+ skills: {},
95
+ mcp: {},
96
+ taskForker: null,
97
+ workspaceManager
98
+ };
99
+ }
100
+ function resolveWorkspacePath(workspaceRoot, filePath) {
101
+ return path.isAbsolute(filePath) ? filePath : path.join(workspaceRoot, filePath);
102
+ }
103
+ async function readWorkspaceFile(workspaceRoot, filePath) {
104
+ try {
105
+ return await fs.readFile(resolveWorkspacePath(workspaceRoot, filePath), "utf-8");
106
+ } catch (error) {
107
+ if (error.code === "ENOENT") {
108
+ throw import_workspace.WorkspaceError.fileNotFound(filePath);
109
+ }
110
+ throw error;
111
+ }
51
112
  }
52
113
  (0, import_vitest.describe)("write_file tool", () => {
53
114
  let mockLogger;
@@ -70,6 +131,7 @@ function createToolContext(logger, overrides = {}) {
70
131
  mockLogger
71
132
  );
72
133
  await fileSystemService.initialize();
134
+ currentWorkspaceRoot = tempDir;
73
135
  import_vitest.vi.clearAllMocks();
74
136
  });
75
137
  (0, import_vitest.afterEach)(async () => {
@@ -78,6 +140,186 @@ function createToolContext(logger, overrides = {}) {
78
140
  } catch {
79
141
  }
80
142
  });
143
+ (0, import_vitest.it)("writes through WorkspaceManager.open without FileSystemService execution", async () => {
144
+ const getFileSystemService = import_vitest.vi.fn(async () => {
145
+ throw new Error("write_file execute must not use FileSystemService");
146
+ });
147
+ const tool = (0, import_write_file_tool.createWriteFileTool)(getFileSystemService);
148
+ const parsedInput = tool.inputSchema.parse({
149
+ file_path: "workspace.txt",
150
+ content: "workspace write"
151
+ });
152
+ const result = await tool.execute(parsedInput, createToolContext(mockLogger));
153
+ (0, import_vitest.expect)(getFileSystemService).not.toHaveBeenCalled();
154
+ (0, import_vitest.expect)(result).toMatchObject({ success: true, path: "workspace.txt" });
155
+ await (0, import_vitest.expect)(fs.readFile(path.join(tempDir, "workspace.txt"), "utf-8")).resolves.toBe(
156
+ "workspace write"
157
+ );
158
+ });
159
+ (0, import_vitest.it)("creates missing parent directories by default", async () => {
160
+ const tool = (0, import_write_file_tool.createWriteFileTool)(import_vitest.vi.fn());
161
+ const parsedInput = tool.inputSchema.parse({
162
+ file_path: "missing-parent/file.txt",
163
+ content: "content"
164
+ });
165
+ await (0, import_vitest.expect)(
166
+ tool.execute(parsedInput, createToolContext(mockLogger))
167
+ ).resolves.toMatchObject({
168
+ success: true,
169
+ path: "missing-parent/file.txt"
170
+ });
171
+ await (0, import_vitest.expect)(
172
+ fs.readFile(path.join(tempDir, "missing-parent", "file.txt"), "utf-8")
173
+ ).resolves.toBe("content");
174
+ });
175
+ (0, import_vitest.it)("rejects obsolete create_dirs input", async () => {
176
+ const tool = (0, import_write_file_tool.createWriteFileTool)(import_vitest.vi.fn());
177
+ (0, import_vitest.expect)(
178
+ () => tool.inputSchema.parse({
179
+ file_path: "file.txt",
180
+ content: "content",
181
+ create_dirs: true
182
+ })
183
+ ).toThrow();
184
+ });
185
+ (0, import_vitest.it)("passes normalized workspace paths to the provider without write options", async () => {
186
+ const writeFile = import_vitest.vi.fn(async () => void 0);
187
+ const workspaceManager = {
188
+ open: import_vitest.vi.fn(async () => ({
189
+ context: {
190
+ id: "test-workspace",
191
+ path: tempDir,
192
+ createdAt: Date.now(),
193
+ lastActiveAt: Date.now()
194
+ },
195
+ capabilities: ["files"],
196
+ files: {
197
+ readFile: import_vitest.vi.fn(async () => {
198
+ throw import_workspace.WorkspaceError.fileNotFound("nested/file.txt");
199
+ }),
200
+ readText: import_vitest.vi.fn(async () => {
201
+ throw import_workspace.WorkspaceError.fileNotFound("nested/file.txt");
202
+ }),
203
+ glob: import_vitest.vi.fn(async () => []),
204
+ writeFile,
205
+ listFiles: import_vitest.vi.fn(async () => [])
206
+ }
207
+ }))
208
+ };
209
+ const tool = (0, import_write_file_tool.createWriteFileTool)(import_vitest.vi.fn());
210
+ const context = createToolContext(mockLogger, {
211
+ services: {
212
+ ...createWorkspaceServices(tempDir),
213
+ workspaceManager
214
+ }
215
+ });
216
+ await tool.execute(
217
+ tool.inputSchema.parse({
218
+ file_path: path.join(tempDir, "nested", "file.txt"),
219
+ content: "content"
220
+ }),
221
+ context
222
+ );
223
+ (0, import_vitest.expect)(writeFile).toHaveBeenCalledWith("nested/file.txt", "content");
224
+ });
225
+ (0, import_vitest.it)("generates file creation previews through WorkspaceManager.open in hosted mode", async () => {
226
+ const getFileSystemService = import_vitest.vi.fn(async () => {
227
+ throw new Error("write_file preview must not use FileSystemService in workspace mode");
228
+ });
229
+ const tool = (0, import_write_file_tool.createWriteFileTool)(getFileSystemService);
230
+ const preview = await tool.presentation.preview(
231
+ tool.inputSchema.parse({
232
+ file_path: "workspace-new.txt",
233
+ content: "workspace content"
234
+ }),
235
+ createToolContext(mockLogger, { toolCallId: "workspace-preview-create" })
236
+ );
237
+ (0, import_vitest.expect)(getFileSystemService).not.toHaveBeenCalled();
238
+ (0, import_vitest.expect)(preview).toMatchObject({
239
+ content: "workspace content",
240
+ operation: "create",
241
+ path: "workspace-new.txt",
242
+ title: "Create file",
243
+ type: "file"
244
+ });
245
+ });
246
+ (0, import_vitest.it)("rejects external absolute paths before file provider calls", async () => {
247
+ const readText = import_vitest.vi.fn(async () => "existing");
248
+ const writeFile = import_vitest.vi.fn(async () => void 0);
249
+ const workspaceManager = {
250
+ open: import_vitest.vi.fn(async () => ({
251
+ context: {
252
+ id: "test-workspace",
253
+ path: tempDir,
254
+ createdAt: Date.now(),
255
+ lastActiveAt: Date.now()
256
+ },
257
+ capabilities: ["files"],
258
+ files: {
259
+ readFile: readText,
260
+ readText,
261
+ glob: import_vitest.vi.fn(async () => []),
262
+ writeFile,
263
+ listFiles: import_vitest.vi.fn(async () => [])
264
+ }
265
+ }))
266
+ };
267
+ const tool = (0, import_write_file_tool.createWriteFileTool)(import_vitest.vi.fn());
268
+ const context = createToolContext(mockLogger, {
269
+ services: {
270
+ ...createWorkspaceServices(tempDir),
271
+ workspaceManager
272
+ }
273
+ });
274
+ await (0, import_vitest.expect)(
275
+ tool.execute(
276
+ tool.inputSchema.parse({ file_path: "/outside/file.txt", content: "content" }),
277
+ context
278
+ )
279
+ ).rejects.toMatchObject({ code: import_core.ToolErrorCode.VALIDATION_FAILED });
280
+ (0, import_vitest.expect)(readText).not.toHaveBeenCalled();
281
+ (0, import_vitest.expect)(writeFile).not.toHaveBeenCalled();
282
+ });
283
+ (0, import_vitest.it)("propagates non-not-found read errors and does not overwrite", async () => {
284
+ const readError = new Error("permission denied");
285
+ const writeFile = import_vitest.vi.fn(async () => void 0);
286
+ const workspaceManager = {
287
+ open: import_vitest.vi.fn(async () => ({
288
+ context: {
289
+ id: "test-workspace",
290
+ path: tempDir,
291
+ createdAt: Date.now(),
292
+ lastActiveAt: Date.now()
293
+ },
294
+ capabilities: ["files"],
295
+ files: {
296
+ readFile: import_vitest.vi.fn(async () => {
297
+ throw readError;
298
+ }),
299
+ readText: import_vitest.vi.fn(async () => {
300
+ throw readError;
301
+ }),
302
+ glob: import_vitest.vi.fn(async () => []),
303
+ writeFile,
304
+ listFiles: import_vitest.vi.fn(async () => [])
305
+ }
306
+ }))
307
+ };
308
+ const tool = (0, import_write_file_tool.createWriteFileTool)(import_vitest.vi.fn());
309
+ const context = createToolContext(mockLogger, {
310
+ services: {
311
+ ...createWorkspaceServices(tempDir),
312
+ workspaceManager
313
+ }
314
+ });
315
+ await (0, import_vitest.expect)(
316
+ tool.execute(
317
+ tool.inputSchema.parse({ file_path: "file.txt", content: "content" }),
318
+ context
319
+ )
320
+ ).rejects.toBe(readError);
321
+ (0, import_vitest.expect)(writeFile).not.toHaveBeenCalled();
322
+ });
81
323
  (0, import_vitest.describe)("File Modification Detection - Existing Files", () => {
82
324
  (0, import_vitest.it)("should generate preview for existing files outside config-allowed roots (preview read only)", async () => {
83
325
  const tool = (0, import_write_file_tool.createWriteFileTool)(async () => fileSystemService);
@@ -95,7 +337,13 @@ function createToolContext(logger, overrides = {}) {
95
337
  });
96
338
  const preview = await tool.presentation.preview(
97
339
  parsedInput,
98
- createToolContext(mockLogger, { toolCallId })
340
+ createToolContext(mockLogger, {
341
+ services: {
342
+ ...createWorkspaceServices(tempDir),
343
+ workspaceManager: void 0
344
+ },
345
+ toolCallId
346
+ })
99
347
  );
100
348
  (0, import_vitest.expect)(preview).toBeDefined();
101
349
  (0, import_vitest.expect)(preview?.type).toBe("diff");
@@ -193,11 +441,10 @@ function createToolContext(logger, overrides = {}) {
193
441
  });
194
442
  });
195
443
  (0, import_vitest.describe)("File Modification Detection - New Files", () => {
196
- (0, import_vitest.it)("should expand home-directory shorthand when creating a file", async () => {
444
+ (0, import_vitest.it)("should create a workspace-relative file", async () => {
197
445
  const homeTempDir = await fs.mkdtemp(
198
446
  path.join(os.homedir(), ".dexto-write-home-test-")
199
447
  );
200
- const homeRelativeDir = `~/${path.basename(homeTempDir)}`;
201
448
  const fileSystemServiceForHome = new import_filesystem_service.FileSystemService(
202
449
  {
203
450
  allowedPaths: [homeTempDir],
@@ -214,14 +461,18 @@ function createToolContext(logger, overrides = {}) {
214
461
  const tool = (0, import_write_file_tool.createWriteFileTool)(async () => fileSystemServiceForHome);
215
462
  try {
216
463
  const parsedInput = tool.inputSchema.parse({
217
- file_path: `${homeRelativeDir}/nested/new-file.txt`,
218
- content: "brand new content",
219
- create_dirs: true
464
+ file_path: "nested/new-file.txt",
465
+ content: "brand new content"
220
466
  });
221
- const result = await tool.execute(parsedInput, createToolContext(mockLogger));
467
+ const result = await tool.execute(
468
+ parsedInput,
469
+ createToolContext(mockLogger, {}, homeTempDir)
470
+ );
222
471
  (0, import_vitest.expect)(result.success).toBe(true);
223
- (0, import_vitest.expect)(result.path).toBe(path.join(homeTempDir, "nested", "new-file.txt"));
224
- (0, import_vitest.expect)(await fs.readFile(result.path, "utf-8")).toBe("brand new content");
472
+ (0, import_vitest.expect)(result.path).toBe("nested/new-file.txt");
473
+ (0, import_vitest.expect)(await fs.readFile(path.join(homeTempDir, result.path), "utf-8")).toBe(
474
+ "brand new content"
475
+ );
225
476
  } finally {
226
477
  await fs.rm(homeTempDir, { recursive: true, force: true });
227
478
  }
@@ -6,6 +6,7 @@ import { createWriteFileTool } from "./write-file-tool.js";
6
6
  import { FileSystemService } from "./filesystem-service.js";
7
7
  import { ToolErrorCode } from "@dexto/core";
8
8
  import { DextoRuntimeError } from "@dexto/core";
9
+ import { WorkspaceError } from "@dexto/core/workspace";
9
10
  const createMockLogger = () => {
10
11
  const logger = {
11
12
  debug: vi.fn(),
@@ -23,8 +24,68 @@ const createMockLogger = () => {
23
24
  };
24
25
  return logger;
25
26
  };
26
- function createToolContext(logger, overrides = {}) {
27
- return { logger, ...overrides };
27
+ function createToolContext(logger, overrides = {}, workspaceRoot = currentWorkspaceRoot) {
28
+ return {
29
+ logger,
30
+ services: createWorkspaceServices(workspaceRoot),
31
+ ...overrides
32
+ };
33
+ }
34
+ let currentWorkspaceRoot = process.cwd();
35
+ function createWorkspaceServices(workspaceRoot, overrides = {}) {
36
+ const workspaceManager = {
37
+ open: vi.fn(async () => ({
38
+ context: {
39
+ id: "test-workspace",
40
+ path: workspaceRoot,
41
+ createdAt: Date.now(),
42
+ lastActiveAt: Date.now()
43
+ },
44
+ capabilities: ["files"],
45
+ files: {
46
+ readFile: async (filePath) => readWorkspaceFile(workspaceRoot, filePath),
47
+ readText: async (filePath) => readWorkspaceFile(workspaceRoot, filePath),
48
+ glob: vi.fn(async () => []),
49
+ writeFile: async (filePath, content) => {
50
+ const resolvedPath = resolveWorkspacePath(workspaceRoot, filePath);
51
+ await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
52
+ try {
53
+ await fs.writeFile(resolvedPath, content, "utf-8");
54
+ } catch (error) {
55
+ if (error.code === "ENOENT") {
56
+ throw WorkspaceError.fileNotFound(filePath);
57
+ }
58
+ throw error;
59
+ }
60
+ },
61
+ listFiles: vi.fn(async () => [])
62
+ }
63
+ })),
64
+ ...overrides
65
+ };
66
+ return {
67
+ approval: {},
68
+ search: {},
69
+ resources: {},
70
+ prompts: {},
71
+ skills: {},
72
+ mcp: {},
73
+ taskForker: null,
74
+ workspaceManager
75
+ };
76
+ }
77
+ function resolveWorkspacePath(workspaceRoot, filePath) {
78
+ return path.isAbsolute(filePath) ? filePath : path.join(workspaceRoot, filePath);
79
+ }
80
+ async function readWorkspaceFile(workspaceRoot, filePath) {
81
+ try {
82
+ return await fs.readFile(resolveWorkspacePath(workspaceRoot, filePath), "utf-8");
83
+ } catch (error) {
84
+ if (error.code === "ENOENT") {
85
+ throw WorkspaceError.fileNotFound(filePath);
86
+ }
87
+ throw error;
88
+ }
28
89
  }
29
90
  describe("write_file tool", () => {
30
91
  let mockLogger;
@@ -47,6 +108,7 @@ describe("write_file tool", () => {
47
108
  mockLogger
48
109
  );
49
110
  await fileSystemService.initialize();
111
+ currentWorkspaceRoot = tempDir;
50
112
  vi.clearAllMocks();
51
113
  });
52
114
  afterEach(async () => {
@@ -55,6 +117,186 @@ describe("write_file tool", () => {
55
117
  } catch {
56
118
  }
57
119
  });
120
+ it("writes through WorkspaceManager.open without FileSystemService execution", async () => {
121
+ const getFileSystemService = vi.fn(async () => {
122
+ throw new Error("write_file execute must not use FileSystemService");
123
+ });
124
+ const tool = createWriteFileTool(getFileSystemService);
125
+ const parsedInput = tool.inputSchema.parse({
126
+ file_path: "workspace.txt",
127
+ content: "workspace write"
128
+ });
129
+ const result = await tool.execute(parsedInput, createToolContext(mockLogger));
130
+ expect(getFileSystemService).not.toHaveBeenCalled();
131
+ expect(result).toMatchObject({ success: true, path: "workspace.txt" });
132
+ await expect(fs.readFile(path.join(tempDir, "workspace.txt"), "utf-8")).resolves.toBe(
133
+ "workspace write"
134
+ );
135
+ });
136
+ it("creates missing parent directories by default", async () => {
137
+ const tool = createWriteFileTool(vi.fn());
138
+ const parsedInput = tool.inputSchema.parse({
139
+ file_path: "missing-parent/file.txt",
140
+ content: "content"
141
+ });
142
+ await expect(
143
+ tool.execute(parsedInput, createToolContext(mockLogger))
144
+ ).resolves.toMatchObject({
145
+ success: true,
146
+ path: "missing-parent/file.txt"
147
+ });
148
+ await expect(
149
+ fs.readFile(path.join(tempDir, "missing-parent", "file.txt"), "utf-8")
150
+ ).resolves.toBe("content");
151
+ });
152
+ it("rejects obsolete create_dirs input", async () => {
153
+ const tool = createWriteFileTool(vi.fn());
154
+ expect(
155
+ () => tool.inputSchema.parse({
156
+ file_path: "file.txt",
157
+ content: "content",
158
+ create_dirs: true
159
+ })
160
+ ).toThrow();
161
+ });
162
+ it("passes normalized workspace paths to the provider without write options", async () => {
163
+ const writeFile = vi.fn(async () => void 0);
164
+ const workspaceManager = {
165
+ open: vi.fn(async () => ({
166
+ context: {
167
+ id: "test-workspace",
168
+ path: tempDir,
169
+ createdAt: Date.now(),
170
+ lastActiveAt: Date.now()
171
+ },
172
+ capabilities: ["files"],
173
+ files: {
174
+ readFile: vi.fn(async () => {
175
+ throw WorkspaceError.fileNotFound("nested/file.txt");
176
+ }),
177
+ readText: vi.fn(async () => {
178
+ throw WorkspaceError.fileNotFound("nested/file.txt");
179
+ }),
180
+ glob: vi.fn(async () => []),
181
+ writeFile,
182
+ listFiles: vi.fn(async () => [])
183
+ }
184
+ }))
185
+ };
186
+ const tool = createWriteFileTool(vi.fn());
187
+ const context = createToolContext(mockLogger, {
188
+ services: {
189
+ ...createWorkspaceServices(tempDir),
190
+ workspaceManager
191
+ }
192
+ });
193
+ await tool.execute(
194
+ tool.inputSchema.parse({
195
+ file_path: path.join(tempDir, "nested", "file.txt"),
196
+ content: "content"
197
+ }),
198
+ context
199
+ );
200
+ expect(writeFile).toHaveBeenCalledWith("nested/file.txt", "content");
201
+ });
202
+ it("generates file creation previews through WorkspaceManager.open in hosted mode", async () => {
203
+ const getFileSystemService = vi.fn(async () => {
204
+ throw new Error("write_file preview must not use FileSystemService in workspace mode");
205
+ });
206
+ const tool = createWriteFileTool(getFileSystemService);
207
+ const preview = await tool.presentation.preview(
208
+ tool.inputSchema.parse({
209
+ file_path: "workspace-new.txt",
210
+ content: "workspace content"
211
+ }),
212
+ createToolContext(mockLogger, { toolCallId: "workspace-preview-create" })
213
+ );
214
+ expect(getFileSystemService).not.toHaveBeenCalled();
215
+ expect(preview).toMatchObject({
216
+ content: "workspace content",
217
+ operation: "create",
218
+ path: "workspace-new.txt",
219
+ title: "Create file",
220
+ type: "file"
221
+ });
222
+ });
223
+ it("rejects external absolute paths before file provider calls", async () => {
224
+ const readText = vi.fn(async () => "existing");
225
+ const writeFile = vi.fn(async () => void 0);
226
+ const workspaceManager = {
227
+ open: vi.fn(async () => ({
228
+ context: {
229
+ id: "test-workspace",
230
+ path: tempDir,
231
+ createdAt: Date.now(),
232
+ lastActiveAt: Date.now()
233
+ },
234
+ capabilities: ["files"],
235
+ files: {
236
+ readFile: readText,
237
+ readText,
238
+ glob: vi.fn(async () => []),
239
+ writeFile,
240
+ listFiles: vi.fn(async () => [])
241
+ }
242
+ }))
243
+ };
244
+ const tool = createWriteFileTool(vi.fn());
245
+ const context = createToolContext(mockLogger, {
246
+ services: {
247
+ ...createWorkspaceServices(tempDir),
248
+ workspaceManager
249
+ }
250
+ });
251
+ await expect(
252
+ tool.execute(
253
+ tool.inputSchema.parse({ file_path: "/outside/file.txt", content: "content" }),
254
+ context
255
+ )
256
+ ).rejects.toMatchObject({ code: ToolErrorCode.VALIDATION_FAILED });
257
+ expect(readText).not.toHaveBeenCalled();
258
+ expect(writeFile).not.toHaveBeenCalled();
259
+ });
260
+ it("propagates non-not-found read errors and does not overwrite", async () => {
261
+ const readError = new Error("permission denied");
262
+ const writeFile = vi.fn(async () => void 0);
263
+ const workspaceManager = {
264
+ open: vi.fn(async () => ({
265
+ context: {
266
+ id: "test-workspace",
267
+ path: tempDir,
268
+ createdAt: Date.now(),
269
+ lastActiveAt: Date.now()
270
+ },
271
+ capabilities: ["files"],
272
+ files: {
273
+ readFile: vi.fn(async () => {
274
+ throw readError;
275
+ }),
276
+ readText: vi.fn(async () => {
277
+ throw readError;
278
+ }),
279
+ glob: vi.fn(async () => []),
280
+ writeFile,
281
+ listFiles: vi.fn(async () => [])
282
+ }
283
+ }))
284
+ };
285
+ const tool = createWriteFileTool(vi.fn());
286
+ const context = createToolContext(mockLogger, {
287
+ services: {
288
+ ...createWorkspaceServices(tempDir),
289
+ workspaceManager
290
+ }
291
+ });
292
+ await expect(
293
+ tool.execute(
294
+ tool.inputSchema.parse({ file_path: "file.txt", content: "content" }),
295
+ context
296
+ )
297
+ ).rejects.toBe(readError);
298
+ expect(writeFile).not.toHaveBeenCalled();
299
+ });
58
300
  describe("File Modification Detection - Existing Files", () => {
59
301
  it("should generate preview for existing files outside config-allowed roots (preview read only)", async () => {
60
302
  const tool = createWriteFileTool(async () => fileSystemService);
@@ -72,7 +314,13 @@ describe("write_file tool", () => {
72
314
  });
73
315
  const preview = await tool.presentation.preview(
74
316
  parsedInput,
75
- createToolContext(mockLogger, { toolCallId })
317
+ createToolContext(mockLogger, {
318
+ services: {
319
+ ...createWorkspaceServices(tempDir),
320
+ workspaceManager: void 0
321
+ },
322
+ toolCallId
323
+ })
76
324
  );
77
325
  expect(preview).toBeDefined();
78
326
  expect(preview?.type).toBe("diff");
@@ -170,11 +418,10 @@ describe("write_file tool", () => {
170
418
  });
171
419
  });
172
420
  describe("File Modification Detection - New Files", () => {
173
- it("should expand home-directory shorthand when creating a file", async () => {
421
+ it("should create a workspace-relative file", async () => {
174
422
  const homeTempDir = await fs.mkdtemp(
175
423
  path.join(os.homedir(), ".dexto-write-home-test-")
176
424
  );
177
- const homeRelativeDir = `~/${path.basename(homeTempDir)}`;
178
425
  const fileSystemServiceForHome = new FileSystemService(
179
426
  {
180
427
  allowedPaths: [homeTempDir],
@@ -191,14 +438,18 @@ describe("write_file tool", () => {
191
438
  const tool = createWriteFileTool(async () => fileSystemServiceForHome);
192
439
  try {
193
440
  const parsedInput = tool.inputSchema.parse({
194
- file_path: `${homeRelativeDir}/nested/new-file.txt`,
195
- content: "brand new content",
196
- create_dirs: true
441
+ file_path: "nested/new-file.txt",
442
+ content: "brand new content"
197
443
  });
198
- const result = await tool.execute(parsedInput, createToolContext(mockLogger));
444
+ const result = await tool.execute(
445
+ parsedInput,
446
+ createToolContext(mockLogger, {}, homeTempDir)
447
+ );
199
448
  expect(result.success).toBe(true);
200
- expect(result.path).toBe(path.join(homeTempDir, "nested", "new-file.txt"));
201
- expect(await fs.readFile(result.path, "utf-8")).toBe("brand new content");
449
+ expect(result.path).toBe("nested/new-file.txt");
450
+ expect(await fs.readFile(path.join(homeTempDir, result.path), "utf-8")).toBe(
451
+ "brand new content"
452
+ );
202
453
  } finally {
203
454
  await fs.rm(homeTempDir, { recursive: true, force: true });
204
455
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dexto/tools-filesystem",
3
- "version": "1.7.1",
3
+ "version": "1.8.0",
4
4
  "description": "FileSystem tools factory for Dexto agents",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,8 +22,8 @@
22
22
  "glob": "^12.0.0",
23
23
  "safe-regex": "^2.1.1",
24
24
  "zod": "^4.3.6",
25
- "@dexto/agent-config": "1.7.1",
26
- "@dexto/core": "1.7.1"
25
+ "@dexto/agent-config": "1.8.0",
26
+ "@dexto/core": "1.8.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/diff": "^5.2.3",