starmux 0.11.0 → 0.13.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 (136) hide show
  1. package/CONTRIBUTING.md +1 -1
  2. package/README.md +169 -97
  3. package/SECURITY.md +3 -3
  4. package/dist/src/access-control.js +23 -20
  5. package/dist/src/access-control.js.map +1 -1
  6. package/dist/src/access-dashboard.js +8 -2
  7. package/dist/src/access-dashboard.js.map +1 -1
  8. package/dist/src/cli/instances.js +87 -14
  9. package/dist/src/cli/instances.js.map +1 -1
  10. package/dist/src/cli/mac-mcp-gateway.js +4 -3
  11. package/dist/src/cli/mac-mcp-gateway.js.map +1 -1
  12. package/dist/src/cli/starmux-status.js +122 -0
  13. package/dist/src/cli/starmux-status.js.map +1 -0
  14. package/dist/src/cli/starmux.js +222 -49
  15. package/dist/src/cli/starmux.js.map +1 -1
  16. package/dist/src/deny-dashboard.js +2 -2
  17. package/dist/src/deny-dashboard.js.map +1 -1
  18. package/dist/src/instance-runtime.js +11 -2
  19. package/dist/src/instance-runtime.js.map +1 -1
  20. package/dist/src/instance-store.js +3 -1
  21. package/dist/src/instance-store.js.map +1 -1
  22. package/dist/src/ops-dashboard.js +4 -4
  23. package/dist/src/ops-dashboard.js.map +1 -1
  24. package/dist/src/permissions-dashboard.js +2 -2
  25. package/dist/src/permissions-dashboard.js.map +1 -1
  26. package/dist/src/runtime-paths.js +3 -3
  27. package/dist/src/runtime-paths.js.map +1 -1
  28. package/dist/src/starmux-migration.js +1 -1
  29. package/dist/src/starmux-migration.js.map +1 -1
  30. package/dist/vendor/desktop-commander/LICENSE +21 -0
  31. package/dist/vendor/desktop-commander/dist/bootstrap.js +22 -0
  32. package/dist/vendor/desktop-commander/dist/command-manager.js +246 -0
  33. package/dist/vendor/desktop-commander/dist/config-field-definitions.js +37 -0
  34. package/dist/vendor/desktop-commander/dist/config-manager.js +634 -0
  35. package/dist/vendor/desktop-commander/dist/config-recovery.js +81 -0
  36. package/dist/vendor/desktop-commander/dist/config.js +19 -0
  37. package/dist/vendor/desktop-commander/dist/custom-stdio.js +358 -0
  38. package/dist/vendor/desktop-commander/dist/error-handlers.js +15 -0
  39. package/dist/vendor/desktop-commander/dist/handlers/edit-search-handlers.js +6 -0
  40. package/dist/vendor/desktop-commander/dist/handlers/filesystem-handlers.js +450 -0
  41. package/dist/vendor/desktop-commander/dist/handlers/history-handlers.js +67 -0
  42. package/dist/vendor/desktop-commander/dist/handlers/index.js +7 -0
  43. package/dist/vendor/desktop-commander/dist/handlers/process-handlers.js +15 -0
  44. package/dist/vendor/desktop-commander/dist/handlers/search-answers.js +172 -0
  45. package/dist/vendor/desktop-commander/dist/handlers/search-handlers.js +136 -0
  46. package/dist/vendor/desktop-commander/dist/handlers/terminal-handlers.js +35 -0
  47. package/dist/vendor/desktop-commander/dist/index.js +88 -0
  48. package/dist/vendor/desktop-commander/dist/search-manager.js +1262 -0
  49. package/dist/vendor/desktop-commander/dist/server.js +1414 -0
  50. package/dist/vendor/desktop-commander/dist/terminal-manager.js +757 -0
  51. package/dist/vendor/desktop-commander/dist/tools/config.js +211 -0
  52. package/dist/vendor/desktop-commander/dist/tools/edit.js +455 -0
  53. package/dist/vendor/desktop-commander/dist/tools/feedback.js +10 -0
  54. package/dist/vendor/desktop-commander/dist/tools/filesystem.js +1047 -0
  55. package/dist/vendor/desktop-commander/dist/tools/fuzzySearch.js +74 -0
  56. package/dist/vendor/desktop-commander/dist/tools/fuzzySearchCore.js +125 -0
  57. package/dist/vendor/desktop-commander/dist/tools/improved-process-tools.js +726 -0
  58. package/dist/vendor/desktop-commander/dist/tools/mime-types.js +28 -0
  59. package/dist/vendor/desktop-commander/dist/tools/pdf/extract-images.js +131 -0
  60. package/dist/vendor/desktop-commander/dist/tools/pdf/index.js +4 -0
  61. package/dist/vendor/desktop-commander/dist/tools/pdf/lib/pdf2md.js +78 -0
  62. package/dist/vendor/desktop-commander/dist/tools/pdf/manipulations.js +112 -0
  63. package/dist/vendor/desktop-commander/dist/tools/pdf/markdown.js +30 -0
  64. package/dist/vendor/desktop-commander/dist/tools/pdf/utils.js +45 -0
  65. package/dist/vendor/desktop-commander/dist/tools/process.js +90 -0
  66. package/dist/vendor/desktop-commander/dist/tools/prompts.js +259 -0
  67. package/dist/vendor/desktop-commander/dist/tools/schemas.js +242 -0
  68. package/dist/vendor/desktop-commander/dist/tools/usage.js +24 -0
  69. package/dist/vendor/desktop-commander/dist/types.js +1 -0
  70. package/dist/vendor/desktop-commander/dist/ui/config-editor/config-editor-runtime.js +150 -0
  71. package/dist/vendor/desktop-commander/dist/ui/config-editor/index.html +13 -0
  72. package/dist/vendor/desktop-commander/dist/ui/config-editor/styles.css +587 -0
  73. package/dist/vendor/desktop-commander/dist/ui/contracts.js +57 -0
  74. package/dist/vendor/desktop-commander/dist/ui/file-preview/index.html +16 -0
  75. package/dist/vendor/desktop-commander/dist/ui/file-preview/preview-runtime.js +471 -0
  76. package/dist/vendor/desktop-commander/dist/ui/file-preview/shared/preview-file-types.js +59 -0
  77. package/dist/vendor/desktop-commander/dist/ui/file-preview/styles.css +1631 -0
  78. package/dist/vendor/desktop-commander/dist/ui/resources.js +93 -0
  79. package/dist/vendor/desktop-commander/dist/utils/ab-test.js +122 -0
  80. package/dist/vendor/desktop-commander/dist/utils/atomic-write.js +80 -0
  81. package/dist/vendor/desktop-commander/dist/utils/capture.js +40 -0
  82. package/dist/vendor/desktop-commander/dist/utils/dockerPrompt.js +9 -0
  83. package/dist/vendor/desktop-commander/dist/utils/exit-process.js +31 -0
  84. package/dist/vendor/desktop-commander/dist/utils/feature-flags.js +18 -0
  85. package/dist/vendor/desktop-commander/dist/utils/files/base.js +5 -0
  86. package/dist/vendor/desktop-commander/dist/utils/files/binary.js +65 -0
  87. package/dist/vendor/desktop-commander/dist/utils/files/docx.js +694 -0
  88. package/dist/vendor/desktop-commander/dist/utils/files/excel.js +497 -0
  89. package/dist/vendor/desktop-commander/dist/utils/files/factory.js +168 -0
  90. package/dist/vendor/desktop-commander/dist/utils/files/image.js +89 -0
  91. package/dist/vendor/desktop-commander/dist/utils/files/index.js +14 -0
  92. package/dist/vendor/desktop-commander/dist/utils/files/pdf.js +124 -0
  93. package/dist/vendor/desktop-commander/dist/utils/files/text.js +423 -0
  94. package/dist/vendor/desktop-commander/dist/utils/fuzzySearchLogger.js +126 -0
  95. package/dist/vendor/desktop-commander/dist/utils/internal-facts.js +23 -0
  96. package/dist/vendor/desktop-commander/dist/utils/lazy-package.js +44 -0
  97. package/dist/vendor/desktop-commander/dist/utils/lineEndingHandler.js +77 -0
  98. package/dist/vendor/desktop-commander/dist/utils/logger.js +72 -0
  99. package/dist/vendor/desktop-commander/dist/utils/mcp-ui-ab-test.js +62 -0
  100. package/dist/vendor/desktop-commander/dist/utils/open-browser.js +2 -0
  101. package/dist/vendor/desktop-commander/dist/utils/process-detection.js +260 -0
  102. package/dist/vendor/desktop-commander/dist/utils/process-tree.js +223 -0
  103. package/dist/vendor/desktop-commander/dist/utils/rename.js +74 -0
  104. package/dist/vendor/desktop-commander/dist/utils/ripgrep-resolver.js +69 -0
  105. package/dist/vendor/desktop-commander/dist/utils/shell.js +187 -0
  106. package/dist/vendor/desktop-commander/dist/utils/system-info.js +727 -0
  107. package/dist/vendor/desktop-commander/dist/utils/terminal-timestamps.js +18 -0
  108. package/dist/vendor/desktop-commander/dist/utils/toolHistory.js +309 -0
  109. package/dist/vendor/desktop-commander/dist/utils/trackTools.js +55 -0
  110. package/dist/vendor/desktop-commander/dist/utils/unsupportedParams.js +66 -0
  111. package/dist/vendor/desktop-commander/dist/utils/usageTracker.js +225 -0
  112. package/dist/vendor/desktop-commander/dist/utils/welcome-onboarding.js +2 -0
  113. package/dist/vendor/desktop-commander/dist/utils/withTimeout.js +85 -0
  114. package/dist/vendor/desktop-commander/dist/version.js +1 -0
  115. package/dist/vendor/desktop-commander/package.json +15 -0
  116. package/docs/ACCESS_CONTROL.md +5 -5
  117. package/docs/CLIENTS.md +11 -19
  118. package/docs/CONNECT_CHATGPT.md +4 -6
  119. package/docs/INSTALL.md +9 -5
  120. package/docs/INSTANCES.md +5 -2
  121. package/docs/OAUTH.md +6 -14
  122. package/docs/PLATFORMS.md +4 -4
  123. package/docs/README.md +2 -11
  124. package/docs/REMOTE_SETUP.md +13 -13
  125. package/docs/SECURITY.md +5 -5
  126. package/docs/assets/starmux-architecture.svg +315 -0
  127. package/package.json +14 -2
  128. package/docs/ARCHITECTURE.md +0 -51
  129. package/docs/MIGRATION.md +0 -37
  130. package/docs/RELEASE_STATUS.md +0 -31
  131. package/docs/TYPESCRIPT_MIGRATION.md +0 -56
  132. package/docs/VALIDATION_OAUTH.md +0 -79
  133. package/docs/VALIDATION_TUNNEL.md +0 -39
  134. package/docs/VALIDATION_WORKBUDDY.md +0 -59
  135. package/docs/assets/mcp-connection-route.svg +0 -104
  136. package/docs/assets/mcp-gateway-simple.svg +0 -62
@@ -0,0 +1,1047 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import os from 'os';
4
+ import { execFile } from 'child_process';
5
+ import { promisify } from 'util';
6
+ import { addToolCallPaths, capture } from '../utils/capture.js';
7
+ import { withTimeout, runWithAbortableTimeout } from '../utils/withTimeout.js';
8
+ import { configManager } from '../config-manager.js';
9
+ import { getFileHandler, TextFileHandler, isImageAnswer } from '../utils/files/index.js';
10
+ import { isPdfFile } from "./mime-types.js";
11
+ import { parsePdfToMarkdown, editPdf, insertRenderOptions, parseMarkdownToPdf, resolveRender } from './pdf/index.js';
12
+ import { movePath } from '../utils/rename.js';
13
+ // CONSTANTS SECTION - Consolidate all timeouts and thresholds
14
+ const FILE_OPERATION_TIMEOUTS = {
15
+ PATH_VALIDATION: 10000, // 10 seconds
16
+ URL_FETCH: 30000, // 30 seconds
17
+ FILE_READ: 30000, // 30 seconds
18
+ };
19
+ // Cap file read operations at 3 minutes. The MCP client's hard per-call limit
20
+ // is ~4 minutes; timing out at 3m lets us abort the underlying fs op and return
21
+ // a useful error (e.g. the cloud-storage guidance in buildPermissionError)
22
+ // BEFORE the client gives up with an opaque "No result received after 4
23
+ // minutes". Paired with runWithAbortableTimeout so the read is actually
24
+ // cancelled (fd/thread released), not just abandoned.
25
+ export const READ_OPERATION_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes
26
+ const FILE_SIZE_LIMITS = {
27
+ LINE_COUNT_LIMIT: 10 * 1024 * 1024, // 10MB for line counting
28
+ };
29
+ // UTILITY FUNCTIONS - Eliminate duplication
30
+ /**
31
+ * Get MIME type information for a file
32
+ * @param filePath Path to the file
33
+ * @returns Object with mimeType and isImage properties
34
+ */
35
+ async function getMimeTypeInfo(filePath) {
36
+ const { getMimeType, isImageFile, isPdfFile } = await import('./mime-types.js');
37
+ const mimeType = getMimeType(filePath);
38
+ const isImage = isImageFile(mimeType);
39
+ const isPdf = isPdfFile(mimeType);
40
+ return { mimeType, isImage, isPdf };
41
+ }
42
+ /**
43
+ * Get file extension for telemetry purposes
44
+ * @param filePath Path to the file
45
+ * @returns Lowercase file extension
46
+ */
47
+ function getFileExtension(filePath) {
48
+ return path.extname(filePath).toLowerCase();
49
+ }
50
+ /**
51
+ * Get default read length from configuration
52
+ * @returns Default number of lines to read
53
+ */
54
+ async function getDefaultReadLength() {
55
+ const config = await configManager.getConfig();
56
+ return config.fileReadLineLimit ?? 1000; // Default to 1000 lines if not set
57
+ }
58
+ /**
59
+ * Returns a helpful error message when a file operation fails with a permission
60
+ * or timeout error.
61
+ *
62
+ * Lists all common causes without path-based detection — the AI receiving this
63
+ * error should inspect the path and inform the user which cause is most likely
64
+ * (e.g. cloud storage folder, network drive, system file, locked file, etc.)
65
+ */
66
+ function buildPermissionError(filePath, errCode) {
67
+ const isMac = process.platform === 'darwin';
68
+ const isTimeout = errCode === 'ETIMEDOUT';
69
+ const lines = [
70
+ `Cannot read file — ${isTimeout ? 'operation timed out' : 'permission denied'} (${errCode}).`,
71
+ `Path: ${filePath}`,
72
+ ``,
73
+ `[AI: based on the path above, determine which cause is most likely and explain it to the user.]`,
74
+ ``,
75
+ `Possible causes and fixes:`,
76
+ ` 1. File is in cloud storage (Google Drive / iCloud / Dropbox / OneDrive) but not downloaded locally.`,
77
+ ` → Right-click the file and choose "Download Now", "Make Available Offline", or "Keep on This Device".`,
78
+ ` 2. Cloud storage app is not running or not signed in.`,
79
+ ` → Open your cloud storage app and make sure it is syncing.`,
80
+ ` 3. File is on a network drive or virtual filesystem that is currently unavailable.`,
81
+ ` → Check that the network share or drive is mounted and accessible.`,
82
+ ` 4. File has restricted permissions (e.g. system file, locked by another process, or chmod 000).`,
83
+ ` → Check file permissions or close any app that may have the file open.`,
84
+ ` 5. The app does not have permission to access this location (macOS Full Disk Access).`,
85
+ ];
86
+ if (isMac) {
87
+ lines.push(` → Go to System Settings → Privacy & Security → Full Disk Access and enable Claude.`);
88
+ lines.push(` → To open that pane directly, run in terminal:`);
89
+ lines.push(` open "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles"`);
90
+ lines.push(` Then find "Claude" in the list and enable the toggle next to it.`);
91
+ }
92
+ else {
93
+ lines.push(` → Check that the app has permission to access this file location.`);
94
+ }
95
+ return new Error(lines.join('\n'));
96
+ }
97
+ // Initialize allowed directories from configuration
98
+ async function getAllowedDirs() {
99
+ try {
100
+ let allowedDirectories;
101
+ const config = await configManager.getConfig();
102
+ if (config.allowedDirectories && Array.isArray(config.allowedDirectories)) {
103
+ allowedDirectories = config.allowedDirectories;
104
+ }
105
+ else {
106
+ // Fall back to default directories if not configured
107
+ allowedDirectories = [
108
+ os.homedir() // User's home directory
109
+ ];
110
+ // Update config with default
111
+ await configManager.setValue('allowedDirectories', allowedDirectories);
112
+ }
113
+ return allowedDirectories;
114
+ }
115
+ catch (error) {
116
+ console.error('Failed to initialize allowed directories:', error);
117
+ // Keep the default permissive path
118
+ }
119
+ return [];
120
+ }
121
+ // Normalize all paths consistently
122
+ function normalizePath(p) {
123
+ return path.normalize(expandHome(p)).toLowerCase();
124
+ }
125
+ function expandHome(filepath) {
126
+ if (filepath.startsWith('~/') || filepath === '~') {
127
+ return path.join(os.homedir(), filepath.slice(1));
128
+ }
129
+ return filepath;
130
+ }
131
+ /**
132
+ * Recursively validates parent directories until it finds a valid one
133
+ * This function handles the case where we need to create nested directories
134
+ * and we need to check if any of the parent directories exist
135
+ *
136
+ * @param directoryPath The path to validate
137
+ * @returns Promise<boolean> True if a valid parent directory was found
138
+ */
139
+ async function validateParentDirectories(directoryPath) {
140
+ const parentDir = path.dirname(directoryPath);
141
+ // Base case: we've reached the root or the same directory (shouldn't happen normally)
142
+ if (parentDir === directoryPath || parentDir === path.dirname(parentDir)) {
143
+ return false;
144
+ }
145
+ try {
146
+ // Check if the parent directory exists
147
+ await fs.realpath(parentDir);
148
+ return true;
149
+ }
150
+ catch {
151
+ // Parent doesn't exist, recursively check its parent
152
+ return validateParentDirectories(parentDir);
153
+ }
154
+ }
155
+ // Most links followed while resolving one path before it counts as a loop (Linux's limit)
156
+ const MAX_LINKS_FOLLOWED = 40;
157
+ /**
158
+ * Resolves a path to where file operations on it land, like fs.realpath, but
159
+ * also for paths that don't exist yet (e.g. a file about to be written).
160
+ *
161
+ * Missing names are kept under their resolved parent. A dangling link (a
162
+ * symlink or Windows junction - Node reports both as symlinks - whose target
163
+ * doesn't exist) is followed to its target, because writing through it creates
164
+ * the target. Relative targets resolve against the link's real directory,
165
+ * chains are followed link by link, and loops fail with ELOOP.
166
+ *
167
+ * @param absolutePath Absolute path to resolve
168
+ * @returns Promise<string> The real path, or where the path would be created
169
+ * @throws Filesystem errors other than ENOENT, or ELOOP for a link loop
170
+ */
171
+ async function resolveRealPath(absolutePath) {
172
+ let linksFollowed = 0;
173
+ const resolveFrom = async (currentPath) => {
174
+ try {
175
+ return await fs.realpath(currentPath, { encoding: 'utf8' });
176
+ }
177
+ catch (error) {
178
+ if (error.code !== 'ENOENT')
179
+ throw error;
180
+ }
181
+ const parent = path.dirname(currentPath);
182
+ if (parent === currentPath) {
183
+ return currentPath; // a root that doesn't exist, e.g. an unmapped drive
184
+ }
185
+ // Resolve the parent first so links along the way are followed, and a
186
+ // relative link target is read against the link's real directory
187
+ const resolvedParent = await resolveFrom(parent);
188
+ const candidate = path.join(resolvedParent, path.basename(currentPath));
189
+ let isLink;
190
+ try {
191
+ isLink = (await fs.lstat(candidate)).isSymbolicLink();
192
+ }
193
+ catch (error) {
194
+ if (error.code !== 'ENOENT')
195
+ throw error;
196
+ return candidate; // doesn't exist yet: this is where it would be created
197
+ }
198
+ if (!isLink) {
199
+ return candidate;
200
+ }
201
+ // Dangling link: a write through it lands on its target, so resolve that instead
202
+ if (++linksFollowed > MAX_LINKS_FOLLOWED) {
203
+ throw Object.assign(new Error(`ELOOP: too many symbolic links encountered, resolving '${absolutePath}'`), { code: 'ELOOP' });
204
+ }
205
+ const target = await fs.readlink(candidate, { encoding: 'utf8' });
206
+ return resolveFrom(path.resolve(resolvedParent, target));
207
+ };
208
+ return resolveFrom(absolutePath);
209
+ }
210
+ /**
211
+ * Resolves the folder an entry is in, like resolveRealPath, and keeps the
212
+ * entry's own name as written: the entry itself (a link, not what it points
213
+ * to), in the letter case given.
214
+ */
215
+ async function resolveEntryPath(absolutePath) {
216
+ const parent = path.dirname(absolutePath);
217
+ if (parent === absolutePath) {
218
+ return resolveRealPath(absolutePath); // a root has no folder above it
219
+ }
220
+ return path.join(await resolveRealPath(parent), path.basename(absolutePath));
221
+ }
222
+ /**
223
+ * An allowed directory's real path, or null when it can't be read. validatePath
224
+ * checks real paths, so an allowed directory reached through a symlink or
225
+ * junction (macOS: /var -> /private/var) must match by its real path too.
226
+ * When the real path can't be read, only the written form matches, which can
227
+ * only deny, never widen, access.
228
+ */
229
+ async function getAllowedDirRealPath(allowedDir) {
230
+ try {
231
+ return await fs.realpath(expandHome(allowedDir));
232
+ }
233
+ catch {
234
+ return null;
235
+ }
236
+ }
237
+ /**
238
+ * Whether a normalized path is an allowed directory or inside it
239
+ */
240
+ function isWithinAllowedDir(normalizedPathToCheck, allowedDir) {
241
+ let normalizedAllowedDir = normalizePath(allowedDir);
242
+ if (normalizedAllowedDir.slice(-1) === path.sep) {
243
+ normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);
244
+ }
245
+ // Check if path is exactly the allowed directory
246
+ if (normalizedPathToCheck === normalizedAllowedDir) {
247
+ return true;
248
+ }
249
+ // Check if path is a subdirectory of the allowed directory
250
+ // Make sure to add a separator to prevent partial directory name matches
251
+ // e.g. /home/user vs /home/username
252
+ // An allowed drive root (D:\, normalized to d:) allows its whole drive this
253
+ // way, whatever the letter: the paths checked here are always absolute (d:\...)
254
+ return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);
255
+ }
256
+ /**
257
+ * Checks if a path is within any of the allowed directories
258
+ *
259
+ * @param pathToCheck Path to check
260
+ * @returns boolean True if path is allowed
261
+ */
262
+ async function isPathAllowed(pathToCheck) {
263
+ // If root directory is allowed, all paths are allowed
264
+ const allowedDirectories = await getAllowedDirs();
265
+ if (allowedDirectories.includes('/') || allowedDirectories.length === 0) {
266
+ return true;
267
+ }
268
+ let normalizedPathToCheck = normalizePath(pathToCheck);
269
+ if (normalizedPathToCheck.slice(-1) === path.sep) {
270
+ normalizedPathToCheck = normalizedPathToCheck.slice(0, -1);
271
+ }
272
+ // As written first: no filesystem call, so an allowed directory on an
273
+ // unresponsive mount can't hold up a path inside another one
274
+ if (allowedDirectories.some((allowedDir) => isWithinAllowedDir(normalizedPathToCheck, allowedDir))) {
275
+ return true;
276
+ }
277
+ // Then by real path: the first match answers, so a real path that never
278
+ // resolves only holds up a path that no other allowed directory contains
279
+ return new Promise((resolve) => {
280
+ let pending = allowedDirectories.length;
281
+ for (const allowedDir of allowedDirectories) {
282
+ void getAllowedDirRealPath(allowedDir).then((realPath) => {
283
+ if (realPath !== null && isWithinAllowedDir(normalizedPathToCheck, realPath)) {
284
+ resolve(true);
285
+ }
286
+ if (--pending === 0) {
287
+ resolve(false);
288
+ }
289
+ });
290
+ }
291
+ });
292
+ }
293
+ /**
294
+ * Validates a path to ensure it can be accessed or created.
295
+ * For existing paths, returns the real path (resolving symlinks).
296
+ * For non-existent paths, returns where they would be created (resolving the
297
+ * existing ancestors and any dangling link), and validates parent directories.
298
+ *
299
+ * With `entry`, the path names an entry to rename (move_file): links are
300
+ * followed up to its folder, and its own name is kept as written, so a link
301
+ * is renamed as a link and a change of letter case is kept. A rename only
302
+ * ever touches that entry, never what a link at that name points to.
303
+ *
304
+ * @param requestedPath The path to validate
305
+ * @returns Promise<string> The validated path
306
+ * @throws Error if the path or its parent directories don't exist or if the path is not allowed
307
+ */
308
+ export async function validatePath(requestedPath, { entry = false } = {}) {
309
+ const validationOperation = async () => {
310
+ // Expand home directory if present
311
+ const expandedPath = expandHome(requestedPath);
312
+ // Convert to absolute path
313
+ const absoluteOriginal = path.isAbsolute(expandedPath)
314
+ ? path.resolve(expandedPath)
315
+ : path.resolve(process.cwd(), expandedPath);
316
+ // Every form of this path an error text may name, so telemetry replaces it whole
317
+ addToolCallPaths(requestedPath, expandedPath, absoluteOriginal);
318
+ // SECURITY: Resolve symlinks (and Windows junctions) to where file operations
319
+ // on this path actually land, and check that location. This covers paths that
320
+ // don't exist yet (a new file under a linked directory) and dangling links:
321
+ // otherwise a link inside an allowed directory pointing to a missing file
322
+ // elsewhere would let a write create that file outside the allowed directories.
323
+ let pathForNextCheck;
324
+ try {
325
+ pathForNextCheck = entry
326
+ ? await resolveEntryPath(absoluteOriginal)
327
+ : await resolveRealPath(absoluteOriginal);
328
+ }
329
+ catch (error) {
330
+ // Permission denied, I/O errors, link loops, ...
331
+ const err = error;
332
+ capture('server_path_realpath_error', {
333
+ error: err.message,
334
+ path: absoluteOriginal
335
+ });
336
+ throw new Error(`Failed to resolve symlink for path: ${absoluteOriginal}. Error: ${err.message}`);
337
+ }
338
+ addToolCallPaths(pathForNextCheck);
339
+ // Check if path is allowed
340
+ if (!(await isPathAllowed(pathForNextCheck))) {
341
+ capture('server_path_validation_error', {
342
+ error: 'Path not allowed',
343
+ allowedDirsCount: (await getAllowedDirs()).length
344
+ });
345
+ throw new Error(`Path not allowed: ${requestedPath}. Must be within one of these directories: ${(await getAllowedDirs()).join(', ')}`);
346
+ }
347
+ // SECURITY: Always return the resolved path (with symlinks resolved) so that
348
+ // all subsequent file operations (read, write, mkdir, etc.) operate on the
349
+ // canonical target, not on a symlink that could point outside allowed directories.
350
+ // pathForNextCheck already holds that resolved path from above.
351
+ // Check if path exists
352
+ try {
353
+ // fs.stat() will automatically follow symlinks, so we get existence info
354
+ await fs.stat(pathForNextCheck);
355
+ return pathForNextCheck;
356
+ }
357
+ catch (error) {
358
+ // Path doesn't exist - validate parent directories
359
+ if (await validateParentDirectories(pathForNextCheck)) {
360
+ // Return the resolved path if a valid parent exists
361
+ // This will be used for folder creation and many other file operations
362
+ return pathForNextCheck;
363
+ }
364
+ // If no valid parent found, return the resolved path anyway
365
+ return pathForNextCheck;
366
+ }
367
+ };
368
+ // Execute with timeout
369
+ const result = await withTimeout(validationOperation(), FILE_OPERATION_TIMEOUTS.PATH_VALIDATION, `Path validation operation`, // Generic name for telemetry
370
+ null);
371
+ if (result === null) {
372
+ // Keep original path in error for AI but a generic message for telemetry
373
+ capture('server_path_validation_timeout', {
374
+ timeoutMs: FILE_OPERATION_TIMEOUTS.PATH_VALIDATION
375
+ });
376
+ throw new Error(`Path validation failed for path: ${requestedPath}`);
377
+ }
378
+ return result;
379
+ }
380
+ /**
381
+ * Read file content from a URL
382
+ * @param url URL to fetch content from
383
+ * @param svgAsImage An SVG is answered as an image, for the file preview widget; otherwise as text
384
+ * @returns File content or file result with metadata
385
+ */
386
+ export async function readFileFromUrl(url, svgAsImage = false) {
387
+ // Set up fetch with timeout
388
+ const controller = new AbortController();
389
+ const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);
390
+ try {
391
+ const response = await fetch(url, {
392
+ signal: controller.signal
393
+ });
394
+ // Clear the timeout since fetch completed
395
+ clearTimeout(timeoutId);
396
+ if (!response.ok) {
397
+ throw new Error(`HTTP error! Status: ${response.status}`);
398
+ }
399
+ // Get MIME type from Content-Type header or infer from URL
400
+ const contentType = response.headers.get('content-type') || 'text/plain';
401
+ const isImage = isImageAnswer(contentType, svgAsImage);
402
+ const isPdf = isPdfFile(contentType) || url.toLowerCase().endsWith('.pdf');
403
+ // NEW: Add PDF handling before image check
404
+ if (isPdf) {
405
+ // Use URL directly - pdfreader handles URL downloads internally
406
+ const pdfResult = await parsePdfToMarkdown(url);
407
+ return {
408
+ content: "",
409
+ mimeType: 'text/plain',
410
+ metadata: {
411
+ isImage: false,
412
+ isPdf: true,
413
+ author: pdfResult.metadata.author,
414
+ title: pdfResult.metadata.title,
415
+ totalPages: pdfResult.metadata.totalPages,
416
+ pages: pdfResult.pages
417
+ }
418
+ };
419
+ }
420
+ else if (isImage) {
421
+ // For images, convert to base64
422
+ const buffer = await response.arrayBuffer();
423
+ const content = Buffer.from(buffer).toString('base64');
424
+ return { content, mimeType: contentType, metadata: { isImage } };
425
+ }
426
+ else {
427
+ // For text content
428
+ const content = await response.text();
429
+ return { content, mimeType: contentType, metadata: { isImage } };
430
+ }
431
+ }
432
+ catch (error) {
433
+ // Clear the timeout to prevent memory leaks
434
+ clearTimeout(timeoutId);
435
+ // Return error information instead of throwing
436
+ const errorMessage = error instanceof DOMException && error.name === 'AbortError'
437
+ ? `URL fetch timed out after ${FILE_OPERATION_TIMEOUTS.URL_FETCH}ms: ${url}`
438
+ : `Failed to fetch URL: ${error instanceof Error ? error.message : String(error)}`;
439
+ throw new Error(errorMessage);
440
+ }
441
+ }
442
+ /**
443
+ * Read file content from the local filesystem
444
+ * @param filePath Path to the file
445
+ * @param options Read options (offset, length, sheet, range)
446
+ * @returns File content or file result with metadata
447
+ */
448
+ export async function readFileFromDisk(filePath, options) {
449
+ const { offset = 0, sheet, range, svgAsImage } = options ?? {};
450
+ let { length } = options ?? {};
451
+ // Add validation for required parameters
452
+ if (!filePath || typeof filePath !== 'string') {
453
+ throw new Error('Invalid file path provided');
454
+ }
455
+ // Get default length from config if not provided
456
+ if (length === undefined) {
457
+ length = await getDefaultReadLength();
458
+ }
459
+ const validPath = await validatePath(filePath);
460
+ // Get file extension for telemetry
461
+ const fileExtension = getFileExtension(validPath);
462
+ // Check if path is a directory — return listing instead of EISDIR error
463
+ try {
464
+ const stats = await fs.stat(validPath);
465
+ if (stats.isDirectory()) {
466
+ const dirListOp = async () => {
467
+ const entries = await listDirectory(validPath);
468
+ const listing = entries.join('\n');
469
+ return {
470
+ content: `This is a directory, not a file. Use the list_directory tool instead of read_file for directories.\n\n${listing}`,
471
+ mimeType: 'text/plain',
472
+ metadata: { isImage: false, isDirectory: true }
473
+ };
474
+ };
475
+ const dirResult = await withTimeout(dirListOp(), FILE_OPERATION_TIMEOUTS.FILE_READ, 'Directory listing fallback', null);
476
+ if (dirResult === null) {
477
+ throw new Error(`Directory listing timed out for: ${filePath}`);
478
+ }
479
+ return dirResult;
480
+ }
481
+ }
482
+ catch (error) {
483
+ // If stat itself failed, fall through to the read path which will produce a proper error.
484
+ // But if this was a directory-listing error, re-throw — don't let it fall into the file-read path.
485
+ const err = error;
486
+ if (err.message?.includes('Directory listing') || err.message?.includes('list_directory')) {
487
+ throw error;
488
+ }
489
+ // stat() failed (e.g. ENOENT) — fall through to the read path below
490
+ }
491
+ // Check file size before attempting to read
492
+ try {
493
+ const stats = await fs.stat(validPath);
494
+ // Capture file extension in telemetry without capturing the file path
495
+ capture('server_read_file', {
496
+ fileExtension: fileExtension,
497
+ offset: offset,
498
+ length: length,
499
+ fileSize: stats.size
500
+ });
501
+ }
502
+ catch (error) {
503
+ console.error('error catch ' + error);
504
+ const errorMessage = error instanceof Error ? error.message : String(error);
505
+ capture('server_read_file_error', { error: errorMessage, fileExtension: fileExtension });
506
+ // If we can't stat the file, continue anyway and let the read operation handle errors
507
+ }
508
+ // Read under an abortable timeout so a hung/stalled read is cancelled
509
+ // (fd/thread freed) rather than leaked until the OS call returns.
510
+ const readOperation = async (signal) => {
511
+ // Get appropriate handler for this file type (async - includes binary detection)
512
+ const handler = await getFileHandler(validPath, { svgAsImage });
513
+ // Use handler to read the file
514
+ const result = await handler.read(validPath, {
515
+ offset,
516
+ length,
517
+ sheet,
518
+ range,
519
+ includeStatusMessage: true,
520
+ signal
521
+ });
522
+ // Return with content as string
523
+ // For images: content is already base64-encoded string from handler
524
+ // For text: content may be string or Buffer, convert to UTF-8 string
525
+ let content;
526
+ if (typeof result.content === 'string') {
527
+ content = result.content;
528
+ }
529
+ else if (result.metadata?.isImage) {
530
+ // Image buffer should be base64 encoded, not UTF-8 converted
531
+ content = result.content.toString('base64');
532
+ }
533
+ else {
534
+ content = result.content.toString('utf8');
535
+ }
536
+ return {
537
+ content,
538
+ mimeType: result.mimeType,
539
+ metadata: result.metadata
540
+ };
541
+ };
542
+ // Execute with a 3-minute, cancellable timeout
543
+ let result;
544
+ try {
545
+ result = await runWithAbortableTimeout((signal) => readOperation(signal), READ_OPERATION_TIMEOUT_MS, `Read file operation for ${filePath}`);
546
+ }
547
+ catch (error) {
548
+ const err = error;
549
+ // runWithAbortableTimeout rejects with an Error whose .code is 'ETIMEDOUT'
550
+ // on timeout; fs rejects with EPERM/EACCES. Map all to the guidance error.
551
+ if (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'ETIMEDOUT') {
552
+ throw buildPermissionError(filePath, err.code);
553
+ }
554
+ throw error;
555
+ }
556
+ if (result == null) {
557
+ // Handles the impossible case where withTimeout resolves to null instead of throwing
558
+ throw new Error('Failed to read the file');
559
+ }
560
+ return result;
561
+ }
562
+ /**
563
+ * Read a file from either the local filesystem or a URL
564
+ * @param filePath Path to the file or URL
565
+ * @param options Read options (isUrl, offset, length, sheet, range)
566
+ * @returns File content or file result with metadata
567
+ */
568
+ export async function readFile(filePath, options) {
569
+ const { isUrl, offset, length, sheet, range, svgAsImage } = options ?? {};
570
+ return isUrl
571
+ ? readFileFromUrl(filePath, svgAsImage)
572
+ : readFileFromDisk(filePath, { offset, length, sheet, range, svgAsImage });
573
+ }
574
+ /**
575
+ * Read file content without status messages for internal operations
576
+ * This function preserves exact file content including original line endings,
577
+ * which is essential for edit operations that need to maintain file formatting.
578
+ * @param filePath Path to the file
579
+ * @param offset Starting line number to read from (default: 0)
580
+ * @param length Maximum number of lines to read (default: from config or 1000)
581
+ * @returns File content without status headers, with preserved line endings
582
+ */
583
+ export async function readFileInternal(filePath, offset = 0, length) {
584
+ // Get default length from config if not provided
585
+ if (length === undefined) {
586
+ length = await getDefaultReadLength();
587
+ }
588
+ const validPath = await validatePath(filePath);
589
+ // Get file extension and MIME type
590
+ const fileExtension = getFileExtension(validPath);
591
+ const { mimeType, isImage } = await getMimeTypeInfo(validPath);
592
+ if (isImage) {
593
+ throw new Error('Cannot read image files as text for internal operations');
594
+ }
595
+ // IMPORTANT: For internal operations (especially edit operations), we must
596
+ // preserve exact file content including original line endings.
597
+ // We cannot use readline-based reading as it strips line endings.
598
+ // Read entire file content preserving line endings, under a 3-minute,
599
+ // cancellable timeout so an edit on a stalled/cloud path can't hang forever
600
+ // (previously this read had no timeout at all).
601
+ const content = await runWithAbortableTimeout((signal) => fs.readFile(validPath, { encoding: 'utf8', signal }), READ_OPERATION_TIMEOUT_MS, `Internal read for ${filePath}`);
602
+ // If we need to apply offset/length, do it while preserving line endings
603
+ if (offset === 0 && length >= Number.MAX_SAFE_INTEGER) {
604
+ // Most common case for edit operations: read entire file
605
+ return content;
606
+ }
607
+ // Handle offset/length by splitting on line boundaries while preserving line endings
608
+ const lines = TextFileHandler.splitLinesPreservingEndings(content);
609
+ // Apply offset and length
610
+ const selectedLines = lines.slice(offset, offset + length);
611
+ // Join back together (this preserves the original line endings)
612
+ return selectedLines.join('');
613
+ }
614
+ export async function writeFile(filePath, content, mode = 'rewrite') {
615
+ const validPath = await validatePath(filePath);
616
+ // Get file extension for telemetry
617
+ const fileExtension = getFileExtension(validPath);
618
+ // Calculate content metrics
619
+ const contentBytes = Buffer.from(content).length;
620
+ const lineCount = TextFileHandler.countLines(content);
621
+ // Capture file extension and operation details in telemetry without capturing the file path
622
+ capture('server_write_file', {
623
+ fileExtension: fileExtension,
624
+ mode: mode,
625
+ contentBytes: contentBytes,
626
+ lineCount: lineCount
627
+ });
628
+ // Get appropriate handler for this file type (async - includes binary detection)
629
+ const handler = await getFileHandler(validPath);
630
+ // Use handler to write the file
631
+ await handler.write(validPath, content, mode);
632
+ }
633
+ /**
634
+ * Reads each file; a failure is that file's `error`. `stillLoadingError` (the
635
+ * server's) gives the error for a file whose package is still loading, which
636
+ * is then not read (utils/files/factory.ts).
637
+ */
638
+ export async function readMultipleFiles(paths, stillLoadingError) {
639
+ return Promise.all(paths.map(async (filePath) => {
640
+ const stillLoading = stillLoadingError?.(filePath);
641
+ if (stillLoading) {
642
+ return { path: filePath, error: stillLoading };
643
+ }
644
+ try {
645
+ const validPath = await validatePath(filePath);
646
+ const fileResult = await readFile(validPath);
647
+ // Handle content conversion properly for images vs text
648
+ let content;
649
+ if (typeof fileResult.content === 'string') {
650
+ content = fileResult.content;
651
+ }
652
+ else if (fileResult.metadata?.isImage) {
653
+ content = fileResult.content.toString('base64');
654
+ }
655
+ else {
656
+ content = fileResult.content.toString('utf8');
657
+ }
658
+ return {
659
+ path: filePath,
660
+ content,
661
+ mimeType: fileResult.mimeType,
662
+ isImage: fileResult.metadata?.isImage ?? false,
663
+ isPdf: fileResult.metadata?.isPdf ?? false,
664
+ payload: fileResult.metadata?.isPdf ? {
665
+ metadata: {
666
+ author: fileResult.metadata.author,
667
+ title: fileResult.metadata.title,
668
+ totalPages: fileResult.metadata.totalPages ?? 0
669
+ },
670
+ pages: fileResult.metadata.pages ?? []
671
+ } : undefined
672
+ };
673
+ }
674
+ catch (error) {
675
+ const errorMessage = error instanceof Error ? error.message : String(error);
676
+ return {
677
+ path: filePath,
678
+ error: errorMessage
679
+ };
680
+ }
681
+ }));
682
+ }
683
+ export async function createDirectory(dirPath) {
684
+ const validPath = await validatePath(dirPath);
685
+ await fs.mkdir(validPath, { recursive: true });
686
+ }
687
+ export async function listDirectory(dirPath, depth = 2) {
688
+ const validPath = await validatePath(dirPath);
689
+ const results = [];
690
+ const MAX_NESTED_ITEMS = 100; // Maximum items to show per nested directory
691
+ async function listRecursive(currentPath, currentDepth, relativePath = '', isTopLevel = true) {
692
+ if (currentDepth <= 0)
693
+ return;
694
+ let entries;
695
+ try {
696
+ entries = await fs.readdir(currentPath, { withFileTypes: true });
697
+ }
698
+ catch (error) {
699
+ const err = error;
700
+ const displayPath = relativePath || path.basename(currentPath);
701
+ // Distinguish "not found" from "permission denied" so AI and UI get accurate info.
702
+ if (err.code === 'ENOENT') {
703
+ results.push(`[NOT_FOUND] ${displayPath} — path does not exist`);
704
+ }
705
+ else if (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'ETIMEDOUT') {
706
+ results.push(`[DENIED] ${displayPath} — not accessible (permission denied, cloud-only file, or Full Disk Access not granted)`);
707
+ }
708
+ else {
709
+ results.push(`[DENIED] ${displayPath}`);
710
+ }
711
+ return;
712
+ }
713
+ // Apply filtering for nested directories (not top level)
714
+ const totalEntries = entries.length;
715
+ let entriesToShow = entries;
716
+ let filteredCount = 0;
717
+ if (!isTopLevel && totalEntries > MAX_NESTED_ITEMS) {
718
+ entriesToShow = entries.slice(0, MAX_NESTED_ITEMS);
719
+ filteredCount = totalEntries - MAX_NESTED_ITEMS;
720
+ }
721
+ for (const entry of entriesToShow) {
722
+ const fullPath = path.join(currentPath, entry.name);
723
+ const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
724
+ // A link (a junction on Windows) to a folder is a folder. A link whose
725
+ // target can't be read stays a [FILE], as every link was before.
726
+ const isDirectory = entry.isDirectory()
727
+ || (entry.isSymbolicLink() && await fs.stat(fullPath).then((stats) => stats.isDirectory(), () => false));
728
+ // Add this entry to results
729
+ results.push(`${isDirectory ? "[DIR]" : "[FILE]"} ${displayPath}`);
730
+ // If it's a directory and we have depth remaining, recurse
731
+ if (isDirectory && currentDepth > 1) {
732
+ try {
733
+ // Validate the path before recursing, and list the folder that was
734
+ // checked: a link can be retargeted between the check and the read
735
+ const validatedPath = await validatePath(fullPath);
736
+ await listRecursive(validatedPath, currentDepth - 1, displayPath, false);
737
+ }
738
+ catch (error) {
739
+ // If validation fails or we can't access it, it will be marked as denied
740
+ // when we try to read it in the recursive call
741
+ continue;
742
+ }
743
+ }
744
+ }
745
+ // Add warning message if items were filtered
746
+ if (filteredCount > 0) {
747
+ const displayPath = relativePath || path.basename(currentPath);
748
+ results.push(`[WARNING] ${displayPath}: ${filteredCount} items hidden (showing first ${MAX_NESTED_ITEMS} of ${totalEntries} total)`);
749
+ }
750
+ }
751
+ // A path that is a file lists as that file. Any other path goes on to the
752
+ // listing, which reports why it can't be read ([NOT_FOUND], [DENIED]).
753
+ const stats = await fs.stat(validPath).catch(() => undefined);
754
+ if (stats?.isFile()) {
755
+ return [`[FILE] ${path.basename(validPath)}`];
756
+ }
757
+ await listRecursive(validPath, depth, '', true);
758
+ return results;
759
+ }
760
+ export async function moveFile(sourcePath, destinationPath) {
761
+ // The entries themselves: a link moves as a link, and a rename that only
762
+ // changes letter case keeps the new case
763
+ const validSourcePath = await validatePath(sourcePath, { entry: true });
764
+ const validDestPath = await validatePath(destinationPath, { entry: true });
765
+ // Across volumes, a copy and then removing the source
766
+ await movePath(validSourcePath, validDestPath);
767
+ }
768
+ export async function searchFiles(rootPath, pattern) {
769
+ // Use the new search manager for better performance
770
+ // This provides a temporary compatibility layer until we fully migrate to search sessions
771
+ const { searchManager } = await import('../search-manager.js');
772
+ try {
773
+ const { sessionId } = await searchManager.startSearch({
774
+ rootPath,
775
+ pattern,
776
+ searchType: 'files',
777
+ ignoreCase: true,
778
+ maxResults: 5000, // Higher limit for compatibility
779
+ earlyTermination: true, // Use early termination for better performance
780
+ });
781
+ // Every result of the session once it is complete - each exactly once.
782
+ // Stop a search still running after 30 seconds; it then completes with what it found
783
+ const stopTimer = setTimeout(() => searchManager.terminateSearch(sessionId), 30000);
784
+ const results = await searchManager.waitForCompletion(sessionId)
785
+ .finally(() => clearTimeout(stopTimer));
786
+ const allResults = results
787
+ .filter(searchResult => searchResult.type === 'file')
788
+ .map(searchResult => searchResult.file);
789
+ // Log only the count of found files, not their paths
790
+ capture('server_search_files_complete', {
791
+ resultsCount: allResults.length,
792
+ patternLength: pattern.length,
793
+ usedRipgrep: true
794
+ });
795
+ return allResults;
796
+ }
797
+ catch (error) {
798
+ // Fallback to original Node.js implementation if ripgrep fails
799
+ capture('server_search_files_ripgrep_fallback', {
800
+ error: error instanceof Error ? error.message : 'Unknown error'
801
+ });
802
+ return await searchFilesNodeJS(rootPath, pattern);
803
+ }
804
+ }
805
+ // Keep the original Node.js implementation as fallback
806
+ async function searchFilesNodeJS(rootPath, pattern) {
807
+ const results = [];
808
+ async function search(currentPath) {
809
+ let entries;
810
+ try {
811
+ entries = await fs.readdir(currentPath, { withFileTypes: true });
812
+ }
813
+ catch (error) {
814
+ return; // Skip this directory on error
815
+ }
816
+ for (const entry of entries) {
817
+ const fullPath = path.join(currentPath, entry.name);
818
+ try {
819
+ await validatePath(fullPath);
820
+ if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {
821
+ results.push(fullPath);
822
+ }
823
+ if (entry.isDirectory()) {
824
+ await search(fullPath);
825
+ }
826
+ }
827
+ catch (error) {
828
+ continue;
829
+ }
830
+ }
831
+ }
832
+ try {
833
+ // Validate root path before starting search
834
+ const validPath = await validatePath(rootPath);
835
+ await search(validPath);
836
+ // Log only the count of found files, not their paths
837
+ capture('server_search_files_complete', {
838
+ resultsCount: results.length,
839
+ patternLength: pattern.length,
840
+ usedRipgrep: false
841
+ });
842
+ return results;
843
+ }
844
+ catch (error) {
845
+ // For telemetry only - sanitize error info
846
+ capture('server_search_files_error', {
847
+ errorType: error instanceof Error ? error.name : 'Unknown',
848
+ error: 'Error with root path',
849
+ isRootPathError: true
850
+ });
851
+ // Re-throw the original error for the caller
852
+ throw error;
853
+ }
854
+ }
855
+ export async function getFileInfo(filePath) {
856
+ const validPath = await validatePath(filePath);
857
+ // Get fs.stat as a fallback for any missing fields
858
+ const stats = await fs.stat(validPath);
859
+ const fallbackInfo = {
860
+ size: stats.size,
861
+ created: stats.birthtime,
862
+ modified: stats.mtime,
863
+ accessed: stats.atime,
864
+ isDirectory: stats.isDirectory(),
865
+ isFile: stats.isFile(),
866
+ permissions: stats.mode.toString(8).slice(-3),
867
+ fileType: (stats.isDirectory() ? 'directory' : 'text'),
868
+ metadata: undefined,
869
+ };
870
+ // Get appropriate handler for this file type (async - includes binary detection).
871
+ // A folder has none: one chosen by its name or content would call it text, or an image.
872
+ const handler = stats.isDirectory() ? null : await getFileHandler(validPath);
873
+ // Use handler to get file info, with fallback
874
+ let fileInfo;
875
+ try {
876
+ fileInfo = handler ? await handler.getInfo(validPath) : fallbackInfo;
877
+ }
878
+ catch (error) {
879
+ // If handler fails, use fallback stats
880
+ fileInfo = fallbackInfo;
881
+ }
882
+ // Convert to legacy format (for backward compatibility)
883
+ // Use handler values with fallback to fs.stat values for any missing fields
884
+ const info = {
885
+ size: fileInfo.size ?? fallbackInfo.size,
886
+ created: fileInfo.created ?? fallbackInfo.created,
887
+ modified: fileInfo.modified ?? fallbackInfo.modified,
888
+ accessed: fileInfo.accessed ?? fallbackInfo.accessed,
889
+ isDirectory: fileInfo.isDirectory ?? fallbackInfo.isDirectory,
890
+ isFile: fileInfo.isFile ?? fallbackInfo.isFile,
891
+ permissions: fileInfo.permissions ?? fallbackInfo.permissions,
892
+ fileType: fileInfo.fileType ?? fallbackInfo.fileType,
893
+ };
894
+ // Add type-specific metadata from file handler
895
+ if (fileInfo.metadata) {
896
+ // For text files
897
+ if (fileInfo.metadata.lineCount !== undefined) {
898
+ info.lineCount = fileInfo.metadata.lineCount;
899
+ info.lastLine = fileInfo.metadata.lineCount - 1;
900
+ info.appendPosition = fileInfo.metadata.lineCount;
901
+ }
902
+ // For Excel files
903
+ if (fileInfo.metadata.sheets) {
904
+ info.sheets = fileInfo.metadata.sheets;
905
+ info.isExcelFile = true;
906
+ }
907
+ // For images
908
+ if (fileInfo.metadata.isImage) {
909
+ info.isImage = true;
910
+ }
911
+ // For PDF files
912
+ if (fileInfo.metadata.isPdf) {
913
+ info.isPdf = true;
914
+ info.totalPages = fileInfo.metadata.totalPages;
915
+ if (fileInfo.metadata.title)
916
+ info.title = fileInfo.metadata.title;
917
+ if (fileInfo.metadata.author)
918
+ info.author = fileInfo.metadata.author;
919
+ }
920
+ // For binary files
921
+ if (fileInfo.metadata.isBinary) {
922
+ info.isBinary = true;
923
+ }
924
+ // A file its handler couldn't read (e.g. a workbook Excel can't open): say why
925
+ if (fileInfo.metadata.error && fileInfo.metadata.errorMessage) {
926
+ info.errorMessage = fileInfo.metadata.errorMessage;
927
+ }
928
+ }
929
+ return info;
930
+ }
931
+ /**
932
+ * Validate the paths a PDF's page operations use besides the PDF itself: the
933
+ * output path and each inserted PDF. write_pdf and edit_block both modify PDFs,
934
+ * so both check here. An inserted PDF's path is replaced by its validated path.
935
+ *
936
+ * @param validPath The PDF the operations apply to, already validated
937
+ * @returns Where to write the result: outputPath if provided, otherwise the PDF itself
938
+ */
939
+ export async function validatePdfOperationPaths(validPath, operations, outputPath) {
940
+ // Use outputPath if provided, otherwise overwrite input file
941
+ const targetPath = outputPath ? await validatePath(outputPath) : validPath;
942
+ // Validate paths in operations
943
+ for (const o of operations) {
944
+ if (o.type === 'insert') {
945
+ if (o.sourcePdfPath) {
946
+ o.sourcePdfPath = await validatePath(o.sourcePdfPath);
947
+ }
948
+ }
949
+ }
950
+ return targetPath;
951
+ }
952
+ /**
953
+ * Write content to a PDF file.
954
+ * Can create a new PDF from Markdown string, or modify an existing PDF using operations.
955
+ *
956
+ * @param filePath Path to the output PDF file
957
+ * @param content Markdown string (for creation) or array of operations (for modification)
958
+ * @param options Options for PDF generation or modification. For modification, can include `sourcePdf`.
959
+ * @returns The render options Desktop Commander ignored (see resolveRender), so the caller can report them.
960
+ */
961
+ export async function writePdf(filePath, content, outputPath, options = {}) {
962
+ const validPath = await validatePath(filePath);
963
+ const fileExtension = getFileExtension(validPath);
964
+ if (typeof content === 'string') {
965
+ // --- PDF CREATION MODE ---
966
+ capture('server_write_pdf', {
967
+ fileExtension: fileExtension,
968
+ contentLength: content.length,
969
+ mode: 'create'
970
+ });
971
+ const pdfBuffer = await parseMarkdownToPdf(content, options);
972
+ // Use outputPath if provided, otherwise overwrite input file
973
+ const targetPath = outputPath ? await validatePath(outputPath) : validPath;
974
+ await fs.writeFile(targetPath, pdfBuffer);
975
+ // The render succeeded; report which of the caller's/front matter's options were ignored
976
+ return resolveRender(content, options).ignoredOptions;
977
+ }
978
+ else if (Array.isArray(content)) {
979
+ const targetPath = await validatePdfOperationPaths(validPath, content, outputPath);
980
+ const operations = [...content];
981
+ capture('server_write_pdf', {
982
+ fileExtension: fileExtension,
983
+ operationCount: operations.length,
984
+ mode: 'modify',
985
+ deleteCount: operations.filter(op => op.type === 'delete').length,
986
+ insertCount: operations.filter(op => op.type === 'insert').length
987
+ });
988
+ // Perform the PDF editing (options render the inserted markdown pages)
989
+ const modifiedPdfBuffer = await editPdf(validPath, operations, options);
990
+ // Write the modified PDF to the output path
991
+ await fs.writeFile(targetPath, modifiedPdfBuffer);
992
+ // Report the options ignored in any inserted page's render options or front matter (once per option)
993
+ const ignored = new Map();
994
+ for (const op of operations) {
995
+ if (op.type === 'insert' && op.markdown !== undefined) {
996
+ for (const ignoredOption of resolveRender(op.markdown, insertRenderOptions(op, options)).ignoredOptions) {
997
+ ignored.set(ignoredOption.option, ignoredOption);
998
+ }
999
+ }
1000
+ }
1001
+ return [...ignored.values()];
1002
+ }
1003
+ else {
1004
+ throw new Error('Invalid content type for writePdf. Expected string (markdown) or array of operations.');
1005
+ }
1006
+ }
1007
+ const execFileAsync = promisify(execFile);
1008
+ const DEFAULT_EDITOR_NEGATIVE_CACHE_MS = 5 * 60 * 1000;
1009
+ const defaultEditorCache = new Map();
1010
+ function escapeAppleScriptString(value) {
1011
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1012
+ }
1013
+ export async function getDefaultEditorMetadata(filePath) {
1014
+ if (os.platform() !== 'darwin') {
1015
+ return {};
1016
+ }
1017
+ let cacheKey = '';
1018
+ try {
1019
+ const extension = path.extname(filePath).toLowerCase();
1020
+ cacheKey = extension || path.basename(filePath).toLowerCase();
1021
+ const cached = defaultEditorCache.get(cacheKey);
1022
+ if (cached) {
1023
+ if (!cached.expiresAt || cached.expiresAt > Date.now()) {
1024
+ return cached.metadata;
1025
+ }
1026
+ defaultEditorCache.delete(cacheKey);
1027
+ }
1028
+ const script = `set appAlias to default application of (info for POSIX file "${escapeAppleScriptString(filePath)}")\nreturn (name of (info for appAlias)) & linefeed & POSIX path of appAlias`;
1029
+ const { stdout } = await execFileAsync('osascript', ['-e', script], { timeout: 12000 });
1030
+ const lines = stdout.split('\n').map((line) => line.trim()).filter(Boolean);
1031
+ const defaultEditorName = lines[lines.length - 2]?.replace(/\.app$/i, '') ?? '';
1032
+ const defaultEditorPath = lines[lines.length - 1] ?? '';
1033
+ if (defaultEditorName && defaultEditorPath.startsWith('/')) {
1034
+ const metadata = { defaultEditorName, defaultEditorPath };
1035
+ defaultEditorCache.set(cacheKey, { metadata });
1036
+ return metadata;
1037
+ }
1038
+ defaultEditorCache.set(cacheKey, { metadata: {}, expiresAt: Date.now() + DEFAULT_EDITOR_NEGATIVE_CACHE_MS });
1039
+ }
1040
+ catch {
1041
+ if (cacheKey) {
1042
+ defaultEditorCache.set(cacheKey, { metadata: {}, expiresAt: Date.now() + DEFAULT_EDITOR_NEGATIVE_CACHE_MS });
1043
+ }
1044
+ // Generic UI fallback is good enough if detection fails.
1045
+ }
1046
+ return {};
1047
+ }