@hcu-lab.me/mcp-device 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +51 -0
- package/dist/bootstrap.d.ts +1 -0
- package/dist/bootstrap.js +22 -0
- package/dist/command-manager.d.ts +8 -0
- package/dist/command-manager.js +241 -0
- package/dist/config-field-definitions.d.ts +41 -0
- package/dist/config-field-definitions.js +37 -0
- package/dist/config-manager.d.ts +102 -0
- package/dist/config-manager.js +291 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +10 -0
- package/dist/custom-stdio.d.ts +51 -0
- package/dist/custom-stdio.js +358 -0
- package/dist/data/official-app-ca.pem +12 -0
- package/dist/data/onboarding-prompts.json +70 -0
- package/dist/device/device-state.d.ts +13 -0
- package/dist/device/device-state.js +100 -0
- package/dist/device/device-status.d.ts +81 -0
- package/dist/device/device-status.js +156 -0
- package/dist/device/device.d.ts +16 -0
- package/dist/device/device.js +190 -0
- package/dist/device/execution-engine.d.ts +144 -0
- package/dist/device/execution-engine.js +195 -0
- package/dist/device/gateway-channel.d.ts +48 -0
- package/dist/device/gateway-channel.js +384 -0
- package/dist/device/gateway-config.d.ts +40 -0
- package/dist/device/gateway-config.js +207 -0
- package/dist/device/gateway-identity.d.ts +35 -0
- package/dist/device/gateway-identity.js +178 -0
- package/dist/device/gateway-pairing.d.ts +25 -0
- package/dist/device/gateway-pairing.js +105 -0
- package/dist/device/gateway-secure-transport.d.ts +25 -0
- package/dist/device/gateway-secure-transport.js +117 -0
- package/dist/device/gateway-tool-adapter.d.ts +17 -0
- package/dist/device/gateway-tool-adapter.js +284 -0
- package/dist/device/gateway-url-policy.d.ts +3 -0
- package/dist/device/gateway-url-policy.js +43 -0
- package/dist/device/linux-service.d.ts +70 -0
- package/dist/device/linux-service.js +268 -0
- package/dist/device/official-trust.d.ts +7 -0
- package/dist/device/official-trust.js +49 -0
- package/dist/device/project-inspection.d.ts +1 -0
- package/dist/device/project-inspection.js +180 -0
- package/dist/device/runtime-owner.d.ts +41 -0
- package/dist/device/runtime-owner.js +177 -0
- package/dist/device/windows-dpapi.d.ts +11 -0
- package/dist/device/windows-dpapi.js +67 -0
- package/dist/device/windows-service.d.ts +56 -0
- package/dist/device/windows-service.js +238 -0
- package/dist/error-handlers.d.ts +7 -0
- package/dist/error-handlers.js +15 -0
- package/dist/handlers/edit-search-handlers.d.ts +6 -0
- package/dist/handlers/edit-search-handlers.js +6 -0
- package/dist/handlers/filesystem-handlers.d.ts +38 -0
- package/dist/handlers/filesystem-handlers.js +440 -0
- package/dist/handlers/history-handlers.d.ts +12 -0
- package/dist/handlers/history-handlers.js +67 -0
- package/dist/handlers/index.d.ts +6 -0
- package/dist/handlers/index.js +7 -0
- package/dist/handlers/process-handlers.d.ts +9 -0
- package/dist/handlers/process-handlers.js +15 -0
- package/dist/handlers/search-handlers.d.ts +17 -0
- package/dist/handlers/search-handlers.js +224 -0
- package/dist/handlers/terminal-handlers.d.ts +21 -0
- package/dist/handlers/terminal-handlers.js +35 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +138 -0
- package/dist/mcp-device.d.ts +2 -0
- package/dist/mcp-device.js +7 -0
- package/dist/npm-scripts/remote.d.ts +3 -0
- package/dist/npm-scripts/remote.js +314 -0
- package/dist/search-manager.d.ts +147 -0
- package/dist/search-manager.js +828 -0
- package/dist/server.d.ts +46 -0
- package/dist/server.js +1563 -0
- package/dist/terminal-manager.d.ts +104 -0
- package/dist/terminal-manager.js +635 -0
- package/dist/tools/config.d.ts +96 -0
- package/dist/tools/config.js +260 -0
- package/dist/tools/edit.d.ts +38 -0
- package/dist/tools/edit.js +435 -0
- package/dist/tools/feedback.d.ts +8 -0
- package/dist/tools/feedback.js +132 -0
- package/dist/tools/filesystem.d.ts +79 -0
- package/dist/tools/filesystem.js +947 -0
- package/dist/tools/fuzzySearch.d.ts +12 -0
- package/dist/tools/fuzzySearch.js +74 -0
- package/dist/tools/fuzzySearchCore.d.ts +52 -0
- package/dist/tools/fuzzySearchCore.js +125 -0
- package/dist/tools/improved-process-tools.d.ts +24 -0
- package/dist/tools/improved-process-tools.js +627 -0
- package/dist/tools/mime-types.d.ts +3 -0
- package/dist/tools/mime-types.js +28 -0
- package/dist/tools/pdf/extract-images.d.ts +34 -0
- package/dist/tools/pdf/extract-images.js +132 -0
- package/dist/tools/pdf/index.d.ts +6 -0
- package/dist/tools/pdf/index.js +3 -0
- package/dist/tools/pdf/lib/pdf2md.d.ts +36 -0
- package/dist/tools/pdf/lib/pdf2md.js +76 -0
- package/dist/tools/pdf/manipulations.d.ts +13 -0
- package/dist/tools/pdf/manipulations.js +99 -0
- package/dist/tools/pdf/markdown.d.ts +26 -0
- package/dist/tools/pdf/markdown.js +275 -0
- package/dist/tools/pdf/utils.d.ts +12 -0
- package/dist/tools/pdf/utils.js +34 -0
- package/dist/tools/process.d.ts +3 -0
- package/dist/tools/process.js +56 -0
- package/dist/tools/prompts.d.ts +28 -0
- package/dist/tools/prompts.js +259 -0
- package/dist/tools/schemas.d.ts +454 -0
- package/dist/tools/schemas.js +242 -0
- package/dist/tools/usage.d.ts +5 -0
- package/dist/tools/usage.js +24 -0
- package/dist/types.d.ts +83 -0
- package/dist/types.js +1 -0
- package/dist/ui/config-editor/config-editor-runtime.js +150 -0
- package/dist/ui/config-editor/index.html +13 -0
- package/dist/ui/config-editor/src/app.d.ts +43 -0
- package/dist/ui/config-editor/src/app.js +831 -0
- package/dist/ui/config-editor/src/array-modal.d.ts +19 -0
- package/dist/ui/config-editor/src/array-modal.js +185 -0
- package/dist/ui/config-editor/src/main.d.ts +1 -0
- package/dist/ui/config-editor/src/main.js +2 -0
- package/dist/ui/config-editor/styles.css +587 -0
- package/dist/ui/contracts.d.ts +14 -0
- package/dist/ui/contracts.js +21 -0
- package/dist/ui/file-preview/index.html +16 -0
- package/dist/ui/file-preview/preview-runtime.js +469 -0
- package/dist/ui/file-preview/shared/preview-file-types.d.ts +5 -0
- package/dist/ui/file-preview/shared/preview-file-types.js +59 -0
- package/dist/ui/file-preview/src/app.d.ts +4 -0
- package/dist/ui/file-preview/src/app.js +680 -0
- package/dist/ui/file-preview/src/components/code-viewer.d.ts +6 -0
- package/dist/ui/file-preview/src/components/code-viewer.js +73 -0
- package/dist/ui/file-preview/src/components/highlighting.d.ts +2 -0
- package/dist/ui/file-preview/src/components/highlighting.js +54 -0
- package/dist/ui/file-preview/src/components/html-renderer.d.ts +5 -0
- package/dist/ui/file-preview/src/components/html-renderer.js +47 -0
- package/dist/ui/file-preview/src/components/markdown-renderer.d.ts +1 -0
- package/dist/ui/file-preview/src/components/markdown-renderer.js +59 -0
- package/dist/ui/file-preview/src/directory-controller.d.ts +8 -0
- package/dist/ui/file-preview/src/directory-controller.js +240 -0
- package/dist/ui/file-preview/src/document-layout.d.ts +20 -0
- package/dist/ui/file-preview/src/document-layout.js +109 -0
- package/dist/ui/file-preview/src/document-outline.d.ts +17 -0
- package/dist/ui/file-preview/src/document-outline.js +97 -0
- package/dist/ui/file-preview/src/document-workspace.d.ts +19 -0
- package/dist/ui/file-preview/src/document-workspace.js +33 -0
- package/dist/ui/file-preview/src/file-type-handlers.d.ts +10 -0
- package/dist/ui/file-preview/src/file-type-handlers.js +109 -0
- package/dist/ui/file-preview/src/host/external-actions.d.ts +8 -0
- package/dist/ui/file-preview/src/host/external-actions.js +55 -0
- package/dist/ui/file-preview/src/host/selection-context.d.ts +9 -0
- package/dist/ui/file-preview/src/host/selection-context.js +106 -0
- package/dist/ui/file-preview/src/image-preview.d.ts +3 -0
- package/dist/ui/file-preview/src/image-preview.js +21 -0
- package/dist/ui/file-preview/src/main.d.ts +1 -0
- package/dist/ui/file-preview/src/main.js +5 -0
- package/dist/ui/file-preview/src/markdown/conflict-dialog.d.ts +40 -0
- package/dist/ui/file-preview/src/markdown/conflict-dialog.js +163 -0
- package/dist/ui/file-preview/src/markdown/controller.d.ts +44 -0
- package/dist/ui/file-preview/src/markdown/controller.js +1043 -0
- package/dist/ui/file-preview/src/markdown/editor.d.ts +131 -0
- package/dist/ui/file-preview/src/markdown/editor.js +1479 -0
- package/dist/ui/file-preview/src/markdown/linking.d.ts +16 -0
- package/dist/ui/file-preview/src/markdown/linking.js +228 -0
- package/dist/ui/file-preview/src/markdown/outline.d.ts +2 -0
- package/dist/ui/file-preview/src/markdown/outline.js +16 -0
- package/dist/ui/file-preview/src/markdown/parser.d.ts +30 -0
- package/dist/ui/file-preview/src/markdown/parser.js +38 -0
- package/dist/ui/file-preview/src/markdown/preview.d.ts +1 -0
- package/dist/ui/file-preview/src/markdown/preview.js +20 -0
- package/dist/ui/file-preview/src/markdown/slugify.d.ts +3 -0
- package/dist/ui/file-preview/src/markdown/slugify.js +31 -0
- package/dist/ui/file-preview/src/markdown/utils.d.ts +1 -0
- package/dist/ui/file-preview/src/markdown/utils.js +15 -0
- package/dist/ui/file-preview/src/model.d.ts +35 -0
- package/dist/ui/file-preview/src/model.js +1 -0
- package/dist/ui/file-preview/src/panel-actions.d.ts +17 -0
- package/dist/ui/file-preview/src/panel-actions.js +182 -0
- package/dist/ui/file-preview/src/path-utils.d.ts +6 -0
- package/dist/ui/file-preview/src/path-utils.js +64 -0
- package/dist/ui/file-preview/src/payload-utils.d.ts +11 -0
- package/dist/ui/file-preview/src/payload-utils.js +119 -0
- package/dist/ui/file-preview/src/types.d.ts +1 -0
- package/dist/ui/file-preview/src/types.js +1 -0
- package/dist/ui/file-preview/styles.css +1631 -0
- package/dist/ui/resources.d.ts +28 -0
- package/dist/ui/resources.js +86 -0
- package/dist/ui/shared/compact-row.d.ts +11 -0
- package/dist/ui/shared/compact-row.js +18 -0
- package/dist/ui/shared/escape-html.d.ts +4 -0
- package/dist/ui/shared/escape-html.js +11 -0
- package/dist/ui/shared/host-context.d.ts +15 -0
- package/dist/ui/shared/host-context.js +51 -0
- package/dist/ui/shared/tool-bridge.d.ts +30 -0
- package/dist/ui/shared/tool-bridge.js +137 -0
- package/dist/ui/shared/tool-shell.d.ts +25 -0
- package/dist/ui/shared/tool-shell.js +107 -0
- package/dist/ui/shared/ui-event-tracker.d.ts +9 -0
- package/dist/ui/shared/ui-event-tracker.js +27 -0
- package/dist/ui/shared/widget-state.d.ts +33 -0
- package/dist/ui/shared/widget-state.js +158 -0
- package/dist/utils/ab-test.d.ts +12 -0
- package/dist/utils/ab-test.js +104 -0
- package/dist/utils/capture.d.ts +35 -0
- package/dist/utils/capture.js +481 -0
- package/dist/utils/dockerPrompt.d.ts +17 -0
- package/dist/utils/dockerPrompt.js +53 -0
- package/dist/utils/feature-flags.d.ts +59 -0
- package/dist/utils/feature-flags.js +215 -0
- package/dist/utils/files/base.d.ts +173 -0
- package/dist/utils/files/base.js +5 -0
- package/dist/utils/files/binary.d.ts +21 -0
- package/dist/utils/files/binary.js +65 -0
- package/dist/utils/files/docx.d.ts +47 -0
- package/dist/utils/files/docx.js +679 -0
- package/dist/utils/files/excel.d.ts +24 -0
- package/dist/utils/files/excel.js +437 -0
- package/dist/utils/files/factory.d.ts +41 -0
- package/dist/utils/files/factory.js +113 -0
- package/dist/utils/files/image.d.ts +21 -0
- package/dist/utils/files/image.js +78 -0
- package/dist/utils/files/index.d.ts +10 -0
- package/dist/utils/files/index.js +13 -0
- package/dist/utils/files/pdf.d.ts +32 -0
- package/dist/utils/files/pdf.js +142 -0
- package/dist/utils/files/text.d.ts +63 -0
- package/dist/utils/files/text.js +370 -0
- package/dist/utils/fuzzySearchLogger.d.ts +30 -0
- package/dist/utils/fuzzySearchLogger.js +126 -0
- package/dist/utils/lineEndingHandler.d.ts +21 -0
- package/dist/utils/lineEndingHandler.js +77 -0
- package/dist/utils/logger.d.ts +32 -0
- package/dist/utils/logger.js +72 -0
- package/dist/utils/mcp-ui-ab-test.d.ts +13 -0
- package/dist/utils/mcp-ui-ab-test.js +62 -0
- package/dist/utils/open-browser.d.ts +9 -0
- package/dist/utils/open-browser.js +42 -0
- package/dist/utils/process-detection.d.ts +23 -0
- package/dist/utils/process-detection.js +150 -0
- package/dist/utils/ripgrep-resolver.d.ts +9 -0
- package/dist/utils/ripgrep-resolver.js +74 -0
- package/dist/utils/system-info.d.ts +71 -0
- package/dist/utils/system-info.js +712 -0
- package/dist/utils/toolHistory.d.ts +86 -0
- package/dist/utils/toolHistory.js +257 -0
- package/dist/utils/trackTools.d.ts +6 -0
- package/dist/utils/trackTools.js +54 -0
- package/dist/utils/unsupportedParams.d.ts +24 -0
- package/dist/utils/unsupportedParams.js +66 -0
- package/dist/utils/usageTracker.d.ts +133 -0
- package/dist/utils/usageTracker.js +477 -0
- package/dist/utils/welcome-onboarding.d.ts +11 -0
- package/dist/utils/welcome-onboarding.js +99 -0
- package/dist/utils/withTimeout.d.ts +28 -0
- package/dist/utils/withTimeout.js +85 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/package.json +131 -0
|
@@ -0,0 +1,947 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import fetch from 'cross-fetch';
|
|
5
|
+
import { execFile } from 'child_process';
|
|
6
|
+
import { promisify } from 'util';
|
|
7
|
+
import { capture } from '../utils/capture.js';
|
|
8
|
+
import { withTimeout, runWithAbortableTimeout } from '../utils/withTimeout.js';
|
|
9
|
+
import { configManager } from '../config-manager.js';
|
|
10
|
+
import { getFileHandler, TextFileHandler } from '../utils/files/index.js';
|
|
11
|
+
import { isPdfFile } from "./mime-types.js";
|
|
12
|
+
import { parsePdfToMarkdown, editPdf, parseMarkdownToPdf } from './pdf/index.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
|
+
/**
|
|
156
|
+
* Checks if a path is within any of the allowed directories
|
|
157
|
+
*
|
|
158
|
+
* @param pathToCheck Path to check
|
|
159
|
+
* @returns boolean True if path is allowed
|
|
160
|
+
*/
|
|
161
|
+
async function isPathAllowed(pathToCheck) {
|
|
162
|
+
// If root directory is allowed, all paths are allowed
|
|
163
|
+
const allowedDirectories = await getAllowedDirs();
|
|
164
|
+
if (allowedDirectories.includes('/') || allowedDirectories.length === 0) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
let normalizedPathToCheck = normalizePath(pathToCheck);
|
|
168
|
+
if (normalizedPathToCheck.slice(-1) === path.sep) {
|
|
169
|
+
normalizedPathToCheck = normalizedPathToCheck.slice(0, -1);
|
|
170
|
+
}
|
|
171
|
+
// Check if the path is within any allowed directory
|
|
172
|
+
const isAllowed = allowedDirectories.some(allowedDir => {
|
|
173
|
+
let normalizedAllowedDir = normalizePath(allowedDir);
|
|
174
|
+
if (normalizedAllowedDir.slice(-1) === path.sep) {
|
|
175
|
+
normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);
|
|
176
|
+
}
|
|
177
|
+
// Check if path is exactly the allowed directory
|
|
178
|
+
if (normalizedPathToCheck === normalizedAllowedDir) {
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
// Check if path is a subdirectory of the allowed directory
|
|
182
|
+
// Make sure to add a separator to prevent partial directory name matches
|
|
183
|
+
// e.g. /home/user vs /home/username
|
|
184
|
+
const subdirCheck = normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);
|
|
185
|
+
if (subdirCheck) {
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
// If allowed directory is the root (C:\ on Windows), allow access to the entire drive
|
|
189
|
+
if (normalizedAllowedDir === 'c:' && process.platform === 'win32') {
|
|
190
|
+
return normalizedPathToCheck.startsWith('c:');
|
|
191
|
+
}
|
|
192
|
+
return false;
|
|
193
|
+
});
|
|
194
|
+
return isAllowed;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Validates a path to ensure it can be accessed or created.
|
|
198
|
+
* For existing paths, returns the real path (resolving symlinks).
|
|
199
|
+
* For non-existent paths, validates parent directories to ensure they exist.
|
|
200
|
+
*
|
|
201
|
+
* @param requestedPath The path to validate
|
|
202
|
+
* @returns Promise<string> The validated path
|
|
203
|
+
* @throws Error if the path or its parent directories don't exist or if the path is not allowed
|
|
204
|
+
*/
|
|
205
|
+
export async function validatePath(requestedPath) {
|
|
206
|
+
const validationOperation = async () => {
|
|
207
|
+
// Expand home directory if present
|
|
208
|
+
const expandedPath = expandHome(requestedPath);
|
|
209
|
+
// Convert to absolute path
|
|
210
|
+
const absoluteOriginal = path.isAbsolute(expandedPath)
|
|
211
|
+
? path.resolve(expandedPath)
|
|
212
|
+
: path.resolve(process.cwd(), expandedPath);
|
|
213
|
+
// Attempt to resolve symlinks to get the real path
|
|
214
|
+
// This will succeed if the path exists and all symlinks in the chain are valid
|
|
215
|
+
// It will fail with ENOENT if:
|
|
216
|
+
// - The path itself doesn't exist, OR
|
|
217
|
+
// - A symlink exists but points to a non-existent target (broken symlink)
|
|
218
|
+
let resolvedRealPath = null;
|
|
219
|
+
try {
|
|
220
|
+
resolvedRealPath = await fs.realpath(absoluteOriginal, { encoding: 'utf8' });
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
const err = error;
|
|
224
|
+
// Only throw for non-ENOENT errors (e.g., permission denied, I/O errors)
|
|
225
|
+
if (!err.code || err.code !== 'ENOENT') {
|
|
226
|
+
capture('server_path_realpath_error', {
|
|
227
|
+
error: err.message,
|
|
228
|
+
path: absoluteOriginal
|
|
229
|
+
});
|
|
230
|
+
throw new Error(`Failed to resolve symlink for path: ${absoluteOriginal}. Error: ${err.message}`);
|
|
231
|
+
}
|
|
232
|
+
// SECURITY FIX: When the full path doesn't exist (e.g., writing a new file),
|
|
233
|
+
// resolve the parent directory to detect symlinks in the path chain.
|
|
234
|
+
// Without this, an attacker could create a symlink inside an allowed directory
|
|
235
|
+
// pointing to a restricted location, then write to a non-existent file through
|
|
236
|
+
// that symlink — bypassing the directory restriction check.
|
|
237
|
+
try {
|
|
238
|
+
const parentDir = path.dirname(absoluteOriginal);
|
|
239
|
+
const resolvedParent = await fs.realpath(parentDir, { encoding: 'utf8' });
|
|
240
|
+
const basename = path.basename(absoluteOriginal);
|
|
241
|
+
resolvedRealPath = path.join(resolvedParent, basename);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
// Parent also doesn't exist — walk up the tree to find
|
|
245
|
+
// the deepest existing ancestor and resolve it
|
|
246
|
+
let current = absoluteOriginal;
|
|
247
|
+
let remaining = [];
|
|
248
|
+
while (true) {
|
|
249
|
+
const parent = path.dirname(current);
|
|
250
|
+
if (parent === current)
|
|
251
|
+
break; // reached filesystem root
|
|
252
|
+
remaining.unshift(path.basename(current));
|
|
253
|
+
current = parent;
|
|
254
|
+
try {
|
|
255
|
+
const resolvedAncestor = await fs.realpath(current, { encoding: 'utf8' });
|
|
256
|
+
resolvedRealPath = path.join(resolvedAncestor, ...remaining);
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// keep walking up
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const pathForNextCheck = resolvedRealPath ?? absoluteOriginal;
|
|
266
|
+
// Direct Gateway device workers are already authenticated and policy-gated by
|
|
267
|
+
// the central gateway. In that dedicated remote context, do not inherit a
|
|
268
|
+
// workstation's historical Desktop Commander directory allowlist. An optional
|
|
269
|
+
// MCP_GATEWAY_ALLOWED_ROOTS restriction is enforced by GatewayToolAdapter before
|
|
270
|
+
// calls reach this filesystem layer.
|
|
271
|
+
const remoteGatewayContext = process.env.MCP_DEVICE_REMOTE === 'true';
|
|
272
|
+
if (!remoteGatewayContext && !(await isPathAllowed(pathForNextCheck))) {
|
|
273
|
+
capture('server_path_validation_error', {
|
|
274
|
+
error: 'Path not allowed',
|
|
275
|
+
allowedDirsCount: (await getAllowedDirs()).length
|
|
276
|
+
});
|
|
277
|
+
throw new Error(`Path not allowed: ${requestedPath}. Must be within one of these directories: ${(await getAllowedDirs()).join(', ')}`);
|
|
278
|
+
}
|
|
279
|
+
// SECURITY: Always return the resolved path (with symlinks resolved) so that
|
|
280
|
+
// all subsequent file operations (read, write, mkdir, etc.) operate on the
|
|
281
|
+
// canonical target, not on a symlink that could point outside allowed directories.
|
|
282
|
+
// pathForNextCheck already holds resolvedRealPath ?? absoluteOriginal from above.
|
|
283
|
+
// Check if path exists
|
|
284
|
+
try {
|
|
285
|
+
// fs.stat() will automatically follow symlinks, so we get existence info
|
|
286
|
+
await fs.stat(pathForNextCheck);
|
|
287
|
+
return pathForNextCheck;
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
// Path doesn't exist - validate parent directories
|
|
291
|
+
if (await validateParentDirectories(pathForNextCheck)) {
|
|
292
|
+
// Return the resolved path if a valid parent exists
|
|
293
|
+
// This will be used for folder creation and many other file operations
|
|
294
|
+
return pathForNextCheck;
|
|
295
|
+
}
|
|
296
|
+
// If no valid parent found, return the resolved path anyway
|
|
297
|
+
return pathForNextCheck;
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
// Execute with timeout
|
|
301
|
+
const result = await withTimeout(validationOperation(), FILE_OPERATION_TIMEOUTS.PATH_VALIDATION, `Path validation operation`, // Generic name for telemetry
|
|
302
|
+
null);
|
|
303
|
+
if (result === null) {
|
|
304
|
+
// Keep original path in error for AI but a generic message for telemetry
|
|
305
|
+
capture('server_path_validation_timeout', {
|
|
306
|
+
timeoutMs: FILE_OPERATION_TIMEOUTS.PATH_VALIDATION
|
|
307
|
+
});
|
|
308
|
+
throw new Error(`Path validation failed for path: ${requestedPath}`);
|
|
309
|
+
}
|
|
310
|
+
return result;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Read file content from a URL
|
|
314
|
+
* @param url URL to fetch content from
|
|
315
|
+
* @returns File content or file result with metadata
|
|
316
|
+
*/
|
|
317
|
+
export async function readFileFromUrl(url) {
|
|
318
|
+
// Import the MIME type utilities
|
|
319
|
+
const { isImageFile } = await import('./mime-types.js');
|
|
320
|
+
// Set up fetch with timeout
|
|
321
|
+
const controller = new AbortController();
|
|
322
|
+
const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);
|
|
323
|
+
try {
|
|
324
|
+
const response = await fetch(url, {
|
|
325
|
+
signal: controller.signal
|
|
326
|
+
});
|
|
327
|
+
// Clear the timeout since fetch completed
|
|
328
|
+
clearTimeout(timeoutId);
|
|
329
|
+
if (!response.ok) {
|
|
330
|
+
throw new Error(`HTTP error! Status: ${response.status}`);
|
|
331
|
+
}
|
|
332
|
+
// Get MIME type from Content-Type header or infer from URL
|
|
333
|
+
const contentType = response.headers.get('content-type') || 'text/plain';
|
|
334
|
+
const isImage = isImageFile(contentType);
|
|
335
|
+
const isPdf = isPdfFile(contentType) || url.toLowerCase().endsWith('.pdf');
|
|
336
|
+
// NEW: Add PDF handling before image check
|
|
337
|
+
if (isPdf) {
|
|
338
|
+
// Use URL directly - pdfreader handles URL downloads internally
|
|
339
|
+
const pdfResult = await parsePdfToMarkdown(url);
|
|
340
|
+
return {
|
|
341
|
+
content: "",
|
|
342
|
+
mimeType: 'text/plain',
|
|
343
|
+
metadata: {
|
|
344
|
+
isImage: false,
|
|
345
|
+
isPdf: true,
|
|
346
|
+
author: pdfResult.metadata.author,
|
|
347
|
+
title: pdfResult.metadata.title,
|
|
348
|
+
totalPages: pdfResult.metadata.totalPages,
|
|
349
|
+
pages: pdfResult.pages
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
else if (isImage) {
|
|
354
|
+
// For images, convert to base64
|
|
355
|
+
const buffer = await response.arrayBuffer();
|
|
356
|
+
const content = Buffer.from(buffer).toString('base64');
|
|
357
|
+
return { content, mimeType: contentType, metadata: { isImage } };
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
// For text content
|
|
361
|
+
const content = await response.text();
|
|
362
|
+
return { content, mimeType: contentType, metadata: { isImage } };
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
// Clear the timeout to prevent memory leaks
|
|
367
|
+
clearTimeout(timeoutId);
|
|
368
|
+
// Return error information instead of throwing
|
|
369
|
+
const errorMessage = error instanceof DOMException && error.name === 'AbortError'
|
|
370
|
+
? `URL fetch timed out after ${FILE_OPERATION_TIMEOUTS.URL_FETCH}ms: ${url}`
|
|
371
|
+
: `Failed to fetch URL: ${error instanceof Error ? error.message : String(error)}`;
|
|
372
|
+
throw new Error(errorMessage);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Read file content from the local filesystem
|
|
377
|
+
* @param filePath Path to the file
|
|
378
|
+
* @param options Read options (offset, length, sheet, range)
|
|
379
|
+
* @returns File content or file result with metadata
|
|
380
|
+
*/
|
|
381
|
+
export async function readFileFromDisk(filePath, options) {
|
|
382
|
+
const { offset = 0, sheet, range } = options ?? {};
|
|
383
|
+
let { length } = options ?? {};
|
|
384
|
+
// Add validation for required parameters
|
|
385
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
386
|
+
throw new Error('Invalid file path provided');
|
|
387
|
+
}
|
|
388
|
+
// Get default length from config if not provided
|
|
389
|
+
if (length === undefined) {
|
|
390
|
+
length = await getDefaultReadLength();
|
|
391
|
+
}
|
|
392
|
+
const validPath = await validatePath(filePath);
|
|
393
|
+
// Get file extension for telemetry
|
|
394
|
+
const fileExtension = getFileExtension(validPath);
|
|
395
|
+
// Check if path is a directory — return listing instead of EISDIR error
|
|
396
|
+
try {
|
|
397
|
+
const stats = await fs.stat(validPath);
|
|
398
|
+
if (stats.isDirectory()) {
|
|
399
|
+
const dirListOp = async () => {
|
|
400
|
+
const entries = await listDirectory(validPath);
|
|
401
|
+
const listing = entries.join('\n');
|
|
402
|
+
return {
|
|
403
|
+
content: `This is a directory, not a file. Use the list_directory tool instead of read_file for directories.\n\n${listing}`,
|
|
404
|
+
mimeType: 'text/plain',
|
|
405
|
+
metadata: { isImage: false, isDirectory: true }
|
|
406
|
+
};
|
|
407
|
+
};
|
|
408
|
+
const dirResult = await withTimeout(dirListOp(), FILE_OPERATION_TIMEOUTS.FILE_READ, 'Directory listing fallback', null);
|
|
409
|
+
if (dirResult === null) {
|
|
410
|
+
throw new Error(`Directory listing timed out for: ${filePath}`);
|
|
411
|
+
}
|
|
412
|
+
return dirResult;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
// If stat itself failed, fall through to the read path which will produce a proper error.
|
|
417
|
+
// But if this was a directory-listing error, re-throw — don't let it fall into the file-read path.
|
|
418
|
+
const err = error;
|
|
419
|
+
if (err.message?.includes('Directory listing') || err.message?.includes('list_directory')) {
|
|
420
|
+
throw error;
|
|
421
|
+
}
|
|
422
|
+
// stat() failed (e.g. ENOENT) — fall through to the read path below
|
|
423
|
+
}
|
|
424
|
+
// Check file size before attempting to read
|
|
425
|
+
try {
|
|
426
|
+
const stats = await fs.stat(validPath);
|
|
427
|
+
// Capture file extension in telemetry without capturing the file path
|
|
428
|
+
capture('server_read_file', {
|
|
429
|
+
fileExtension: fileExtension,
|
|
430
|
+
offset: offset,
|
|
431
|
+
length: length,
|
|
432
|
+
fileSize: stats.size
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
console.error('error catch ' + error);
|
|
437
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
438
|
+
capture('server_read_file_error', { error: errorMessage, fileExtension: fileExtension });
|
|
439
|
+
// If we can't stat the file, continue anyway and let the read operation handle errors
|
|
440
|
+
}
|
|
441
|
+
// Read under an abortable timeout so a hung/stalled read is cancelled
|
|
442
|
+
// (fd/thread freed) rather than leaked until the OS call returns.
|
|
443
|
+
const readOperation = async (signal) => {
|
|
444
|
+
// Get appropriate handler for this file type (async - includes binary detection)
|
|
445
|
+
const handler = await getFileHandler(validPath);
|
|
446
|
+
// Use handler to read the file
|
|
447
|
+
const result = await handler.read(validPath, {
|
|
448
|
+
offset,
|
|
449
|
+
length,
|
|
450
|
+
sheet,
|
|
451
|
+
range,
|
|
452
|
+
includeStatusMessage: true,
|
|
453
|
+
signal
|
|
454
|
+
});
|
|
455
|
+
// Return with content as string
|
|
456
|
+
// For images: content is already base64-encoded string from handler
|
|
457
|
+
// For text: content may be string or Buffer, convert to UTF-8 string
|
|
458
|
+
let content;
|
|
459
|
+
if (typeof result.content === 'string') {
|
|
460
|
+
content = result.content;
|
|
461
|
+
}
|
|
462
|
+
else if (result.metadata?.isImage) {
|
|
463
|
+
// Image buffer should be base64 encoded, not UTF-8 converted
|
|
464
|
+
content = result.content.toString('base64');
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
content = result.content.toString('utf8');
|
|
468
|
+
}
|
|
469
|
+
return {
|
|
470
|
+
content,
|
|
471
|
+
mimeType: result.mimeType,
|
|
472
|
+
metadata: result.metadata
|
|
473
|
+
};
|
|
474
|
+
};
|
|
475
|
+
// Execute with a 3-minute, cancellable timeout
|
|
476
|
+
let result;
|
|
477
|
+
try {
|
|
478
|
+
result = await runWithAbortableTimeout((signal) => readOperation(signal), READ_OPERATION_TIMEOUT_MS, `Read file operation for ${filePath}`);
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
const err = error;
|
|
482
|
+
// runWithAbortableTimeout rejects with an Error whose .code is 'ETIMEDOUT'
|
|
483
|
+
// on timeout; fs rejects with EPERM/EACCES. Map all to the guidance error.
|
|
484
|
+
if (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'ETIMEDOUT') {
|
|
485
|
+
throw buildPermissionError(filePath, err.code);
|
|
486
|
+
}
|
|
487
|
+
throw error;
|
|
488
|
+
}
|
|
489
|
+
if (result == null) {
|
|
490
|
+
// Handles the impossible case where withTimeout resolves to null instead of throwing
|
|
491
|
+
throw new Error('Failed to read the file');
|
|
492
|
+
}
|
|
493
|
+
return result;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Read a file from either the local filesystem or a URL
|
|
497
|
+
* @param filePath Path to the file or URL
|
|
498
|
+
* @param options Read options (isUrl, offset, length, sheet, range)
|
|
499
|
+
* @returns File content or file result with metadata
|
|
500
|
+
*/
|
|
501
|
+
export async function readFile(filePath, options) {
|
|
502
|
+
const { isUrl, offset, length, sheet, range } = options ?? {};
|
|
503
|
+
return isUrl
|
|
504
|
+
? readFileFromUrl(filePath)
|
|
505
|
+
: readFileFromDisk(filePath, { offset, length, sheet, range });
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Read file content without status messages for internal operations
|
|
509
|
+
* This function preserves exact file content including original line endings,
|
|
510
|
+
* which is essential for edit operations that need to maintain file formatting.
|
|
511
|
+
* @param filePath Path to the file
|
|
512
|
+
* @param offset Starting line number to read from (default: 0)
|
|
513
|
+
* @param length Maximum number of lines to read (default: from config or 1000)
|
|
514
|
+
* @returns File content without status headers, with preserved line endings
|
|
515
|
+
*/
|
|
516
|
+
export async function readFileInternal(filePath, offset = 0, length) {
|
|
517
|
+
// Get default length from config if not provided
|
|
518
|
+
if (length === undefined) {
|
|
519
|
+
length = await getDefaultReadLength();
|
|
520
|
+
}
|
|
521
|
+
const validPath = await validatePath(filePath);
|
|
522
|
+
// Get file extension and MIME type
|
|
523
|
+
const fileExtension = getFileExtension(validPath);
|
|
524
|
+
const { mimeType, isImage } = await getMimeTypeInfo(validPath);
|
|
525
|
+
if (isImage) {
|
|
526
|
+
throw new Error('Cannot read image files as text for internal operations');
|
|
527
|
+
}
|
|
528
|
+
// IMPORTANT: For internal operations (especially edit operations), we must
|
|
529
|
+
// preserve exact file content including original line endings.
|
|
530
|
+
// We cannot use readline-based reading as it strips line endings.
|
|
531
|
+
// Read entire file content preserving line endings, under a 3-minute,
|
|
532
|
+
// cancellable timeout so an edit on a stalled/cloud path can't hang forever
|
|
533
|
+
// (previously this read had no timeout at all).
|
|
534
|
+
const content = await runWithAbortableTimeout((signal) => fs.readFile(validPath, { encoding: 'utf8', signal }), READ_OPERATION_TIMEOUT_MS, `Internal read for ${filePath}`);
|
|
535
|
+
// If we need to apply offset/length, do it while preserving line endings
|
|
536
|
+
if (offset === 0 && length >= Number.MAX_SAFE_INTEGER) {
|
|
537
|
+
// Most common case for edit operations: read entire file
|
|
538
|
+
return content;
|
|
539
|
+
}
|
|
540
|
+
// Handle offset/length by splitting on line boundaries while preserving line endings
|
|
541
|
+
const lines = TextFileHandler.splitLinesPreservingEndings(content);
|
|
542
|
+
// Apply offset and length
|
|
543
|
+
const selectedLines = lines.slice(offset, offset + length);
|
|
544
|
+
// Join back together (this preserves the original line endings)
|
|
545
|
+
return selectedLines.join('');
|
|
546
|
+
}
|
|
547
|
+
export async function writeFile(filePath, content, mode = 'rewrite') {
|
|
548
|
+
const validPath = await validatePath(filePath);
|
|
549
|
+
// Get file extension for telemetry
|
|
550
|
+
const fileExtension = getFileExtension(validPath);
|
|
551
|
+
// Calculate content metrics
|
|
552
|
+
const contentBytes = Buffer.from(content).length;
|
|
553
|
+
const lineCount = TextFileHandler.countLines(content);
|
|
554
|
+
// Capture file extension and operation details in telemetry without capturing the file path
|
|
555
|
+
capture('server_write_file', {
|
|
556
|
+
fileExtension: fileExtension,
|
|
557
|
+
mode: mode,
|
|
558
|
+
contentBytes: contentBytes,
|
|
559
|
+
lineCount: lineCount
|
|
560
|
+
});
|
|
561
|
+
// Get appropriate handler for this file type (async - includes binary detection)
|
|
562
|
+
const handler = await getFileHandler(validPath);
|
|
563
|
+
// Use handler to write the file
|
|
564
|
+
await handler.write(validPath, content, mode);
|
|
565
|
+
}
|
|
566
|
+
export async function readMultipleFiles(paths) {
|
|
567
|
+
return Promise.all(paths.map(async (filePath) => {
|
|
568
|
+
try {
|
|
569
|
+
const validPath = await validatePath(filePath);
|
|
570
|
+
const fileResult = await readFile(validPath);
|
|
571
|
+
// Handle content conversion properly for images vs text
|
|
572
|
+
let content;
|
|
573
|
+
if (typeof fileResult.content === 'string') {
|
|
574
|
+
content = fileResult.content;
|
|
575
|
+
}
|
|
576
|
+
else if (fileResult.metadata?.isImage) {
|
|
577
|
+
content = fileResult.content.toString('base64');
|
|
578
|
+
}
|
|
579
|
+
else {
|
|
580
|
+
content = fileResult.content.toString('utf8');
|
|
581
|
+
}
|
|
582
|
+
return {
|
|
583
|
+
path: filePath,
|
|
584
|
+
content,
|
|
585
|
+
mimeType: fileResult.mimeType,
|
|
586
|
+
isImage: fileResult.metadata?.isImage ?? false,
|
|
587
|
+
isPdf: fileResult.metadata?.isPdf ?? false,
|
|
588
|
+
payload: fileResult.metadata?.isPdf ? {
|
|
589
|
+
metadata: {
|
|
590
|
+
author: fileResult.metadata.author,
|
|
591
|
+
title: fileResult.metadata.title,
|
|
592
|
+
totalPages: fileResult.metadata.totalPages ?? 0
|
|
593
|
+
},
|
|
594
|
+
pages: fileResult.metadata.pages ?? []
|
|
595
|
+
} : undefined
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
catch (error) {
|
|
599
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
600
|
+
return {
|
|
601
|
+
path: filePath,
|
|
602
|
+
error: errorMessage
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
}));
|
|
606
|
+
}
|
|
607
|
+
export async function createDirectory(dirPath) {
|
|
608
|
+
const validPath = await validatePath(dirPath);
|
|
609
|
+
await fs.mkdir(validPath, { recursive: true });
|
|
610
|
+
}
|
|
611
|
+
export async function listDirectory(dirPath, depth = 2) {
|
|
612
|
+
const validPath = await validatePath(dirPath);
|
|
613
|
+
const results = [];
|
|
614
|
+
const MAX_NESTED_ITEMS = 100; // Maximum items to show per nested directory
|
|
615
|
+
async function listRecursive(currentPath, currentDepth, relativePath = '', isTopLevel = true) {
|
|
616
|
+
if (currentDepth <= 0)
|
|
617
|
+
return;
|
|
618
|
+
let entries;
|
|
619
|
+
try {
|
|
620
|
+
entries = await fs.readdir(currentPath, { withFileTypes: true });
|
|
621
|
+
}
|
|
622
|
+
catch (error) {
|
|
623
|
+
const err = error;
|
|
624
|
+
const displayPath = relativePath || path.basename(currentPath);
|
|
625
|
+
// Distinguish "not found" from "permission denied" so AI and UI get accurate info.
|
|
626
|
+
if (err.code === 'ENOENT') {
|
|
627
|
+
results.push(`[NOT_FOUND] ${displayPath} — path does not exist`);
|
|
628
|
+
}
|
|
629
|
+
else if (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'ETIMEDOUT') {
|
|
630
|
+
results.push(`[DENIED] ${displayPath} — not accessible (permission denied, cloud-only file, or Full Disk Access not granted)`);
|
|
631
|
+
}
|
|
632
|
+
else {
|
|
633
|
+
results.push(`[DENIED] ${displayPath}`);
|
|
634
|
+
}
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
// Apply filtering for nested directories (not top level)
|
|
638
|
+
const totalEntries = entries.length;
|
|
639
|
+
let entriesToShow = entries;
|
|
640
|
+
let filteredCount = 0;
|
|
641
|
+
if (!isTopLevel && totalEntries > MAX_NESTED_ITEMS) {
|
|
642
|
+
entriesToShow = entries.slice(0, MAX_NESTED_ITEMS);
|
|
643
|
+
filteredCount = totalEntries - MAX_NESTED_ITEMS;
|
|
644
|
+
}
|
|
645
|
+
for (const entry of entriesToShow) {
|
|
646
|
+
const fullPath = path.join(currentPath, entry.name);
|
|
647
|
+
const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
|
|
648
|
+
// Add this entry to results
|
|
649
|
+
results.push(`${entry.isDirectory() ? "[DIR]" : "[FILE]"} ${displayPath}`);
|
|
650
|
+
// If it's a directory and we have depth remaining, recurse
|
|
651
|
+
if (entry.isDirectory() && currentDepth > 1) {
|
|
652
|
+
try {
|
|
653
|
+
// Validate the path before recursing
|
|
654
|
+
await validatePath(fullPath);
|
|
655
|
+
await listRecursive(fullPath, currentDepth - 1, displayPath, false);
|
|
656
|
+
}
|
|
657
|
+
catch (error) {
|
|
658
|
+
// If validation fails or we can't access it, it will be marked as denied
|
|
659
|
+
// when we try to read it in the recursive call
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
// Add warning message if items were filtered
|
|
665
|
+
if (filteredCount > 0) {
|
|
666
|
+
const displayPath = relativePath || path.basename(currentPath);
|
|
667
|
+
results.push(`[WARNING] ${displayPath}: ${filteredCount} items hidden (showing first ${MAX_NESTED_ITEMS} of ${totalEntries} total)`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
await listRecursive(validPath, depth, '', true);
|
|
671
|
+
return results;
|
|
672
|
+
}
|
|
673
|
+
export async function moveFile(sourcePath, destinationPath) {
|
|
674
|
+
const validSourcePath = await validatePath(sourcePath);
|
|
675
|
+
const validDestPath = await validatePath(destinationPath);
|
|
676
|
+
await fs.rename(validSourcePath, validDestPath);
|
|
677
|
+
}
|
|
678
|
+
export async function searchFiles(rootPath, pattern) {
|
|
679
|
+
// Use the new search manager for better performance
|
|
680
|
+
// This provides a temporary compatibility layer until we fully migrate to search sessions
|
|
681
|
+
const { searchManager } = await import('../search-manager.js');
|
|
682
|
+
try {
|
|
683
|
+
const result = await searchManager.startSearch({
|
|
684
|
+
rootPath,
|
|
685
|
+
pattern,
|
|
686
|
+
searchType: 'files',
|
|
687
|
+
ignoreCase: true,
|
|
688
|
+
maxResults: 5000, // Higher limit for compatibility
|
|
689
|
+
earlyTermination: true, // Use early termination for better performance
|
|
690
|
+
});
|
|
691
|
+
const sessionId = result.sessionId;
|
|
692
|
+
// Poll for results until complete
|
|
693
|
+
let allResults = [];
|
|
694
|
+
let isComplete = result.isComplete;
|
|
695
|
+
let startTime = Date.now();
|
|
696
|
+
// Add initial results
|
|
697
|
+
for (const searchResult of result.results) {
|
|
698
|
+
if (searchResult.type === 'file') {
|
|
699
|
+
allResults.push(searchResult.file);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
while (!isComplete) {
|
|
703
|
+
await new Promise(resolve => setTimeout(resolve, 100)); // Wait 100ms
|
|
704
|
+
const results = searchManager.readSearchResults(sessionId);
|
|
705
|
+
isComplete = results.isComplete;
|
|
706
|
+
// Add new file paths to results
|
|
707
|
+
for (const searchResult of results.results) {
|
|
708
|
+
if (searchResult.file !== '__LAST_READ_MARKER__' && searchResult.type === 'file') {
|
|
709
|
+
allResults.push(searchResult.file);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
// Safety check to prevent infinite loops (30 second timeout)
|
|
713
|
+
if (Date.now() - startTime > 30000) {
|
|
714
|
+
searchManager.terminateSearch(sessionId);
|
|
715
|
+
break;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
// Log only the count of found files, not their paths
|
|
719
|
+
capture('server_search_files_complete', {
|
|
720
|
+
resultsCount: allResults.length,
|
|
721
|
+
patternLength: pattern.length,
|
|
722
|
+
usedRipgrep: true
|
|
723
|
+
});
|
|
724
|
+
return allResults;
|
|
725
|
+
}
|
|
726
|
+
catch (error) {
|
|
727
|
+
// Fallback to original Node.js implementation if ripgrep fails
|
|
728
|
+
capture('server_search_files_ripgrep_fallback', {
|
|
729
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
730
|
+
});
|
|
731
|
+
return await searchFilesNodeJS(rootPath, pattern);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
// Keep the original Node.js implementation as fallback
|
|
735
|
+
async function searchFilesNodeJS(rootPath, pattern) {
|
|
736
|
+
const results = [];
|
|
737
|
+
async function search(currentPath) {
|
|
738
|
+
let entries;
|
|
739
|
+
try {
|
|
740
|
+
entries = await fs.readdir(currentPath, { withFileTypes: true });
|
|
741
|
+
}
|
|
742
|
+
catch (error) {
|
|
743
|
+
return; // Skip this directory on error
|
|
744
|
+
}
|
|
745
|
+
for (const entry of entries) {
|
|
746
|
+
const fullPath = path.join(currentPath, entry.name);
|
|
747
|
+
try {
|
|
748
|
+
await validatePath(fullPath);
|
|
749
|
+
if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {
|
|
750
|
+
results.push(fullPath);
|
|
751
|
+
}
|
|
752
|
+
if (entry.isDirectory()) {
|
|
753
|
+
await search(fullPath);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
catch (error) {
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
try {
|
|
762
|
+
// Validate root path before starting search
|
|
763
|
+
const validPath = await validatePath(rootPath);
|
|
764
|
+
await search(validPath);
|
|
765
|
+
// Log only the count of found files, not their paths
|
|
766
|
+
capture('server_search_files_complete', {
|
|
767
|
+
resultsCount: results.length,
|
|
768
|
+
patternLength: pattern.length,
|
|
769
|
+
usedRipgrep: false
|
|
770
|
+
});
|
|
771
|
+
return results;
|
|
772
|
+
}
|
|
773
|
+
catch (error) {
|
|
774
|
+
// For telemetry only - sanitize error info
|
|
775
|
+
capture('server_search_files_error', {
|
|
776
|
+
errorType: error instanceof Error ? error.name : 'Unknown',
|
|
777
|
+
error: 'Error with root path',
|
|
778
|
+
isRootPathError: true
|
|
779
|
+
});
|
|
780
|
+
// Re-throw the original error for the caller
|
|
781
|
+
throw error;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
export async function getFileInfo(filePath) {
|
|
785
|
+
const validPath = await validatePath(filePath);
|
|
786
|
+
// Get fs.stat as a fallback for any missing fields
|
|
787
|
+
const stats = await fs.stat(validPath);
|
|
788
|
+
const fallbackInfo = {
|
|
789
|
+
size: stats.size,
|
|
790
|
+
created: stats.birthtime,
|
|
791
|
+
modified: stats.mtime,
|
|
792
|
+
accessed: stats.atime,
|
|
793
|
+
isDirectory: stats.isDirectory(),
|
|
794
|
+
isFile: stats.isFile(),
|
|
795
|
+
permissions: stats.mode.toString(8).slice(-3),
|
|
796
|
+
fileType: 'text',
|
|
797
|
+
metadata: undefined,
|
|
798
|
+
};
|
|
799
|
+
// Get appropriate handler for this file type (async - includes binary detection)
|
|
800
|
+
const handler = await getFileHandler(validPath);
|
|
801
|
+
// Use handler to get file info, with fallback
|
|
802
|
+
let fileInfo;
|
|
803
|
+
try {
|
|
804
|
+
fileInfo = await handler.getInfo(validPath);
|
|
805
|
+
}
|
|
806
|
+
catch (error) {
|
|
807
|
+
// If handler fails, use fallback stats
|
|
808
|
+
fileInfo = fallbackInfo;
|
|
809
|
+
}
|
|
810
|
+
// Convert to legacy format (for backward compatibility)
|
|
811
|
+
// Use handler values with fallback to fs.stat values for any missing fields
|
|
812
|
+
const info = {
|
|
813
|
+
size: fileInfo.size ?? fallbackInfo.size,
|
|
814
|
+
created: fileInfo.created ?? fallbackInfo.created,
|
|
815
|
+
modified: fileInfo.modified ?? fallbackInfo.modified,
|
|
816
|
+
accessed: fileInfo.accessed ?? fallbackInfo.accessed,
|
|
817
|
+
isDirectory: fileInfo.isDirectory ?? fallbackInfo.isDirectory,
|
|
818
|
+
isFile: fileInfo.isFile ?? fallbackInfo.isFile,
|
|
819
|
+
permissions: fileInfo.permissions ?? fallbackInfo.permissions,
|
|
820
|
+
fileType: fileInfo.fileType ?? fallbackInfo.fileType,
|
|
821
|
+
};
|
|
822
|
+
// Add type-specific metadata from file handler
|
|
823
|
+
if (fileInfo.metadata) {
|
|
824
|
+
// For text files
|
|
825
|
+
if (fileInfo.metadata.lineCount !== undefined) {
|
|
826
|
+
info.lineCount = fileInfo.metadata.lineCount;
|
|
827
|
+
info.lastLine = fileInfo.metadata.lineCount - 1;
|
|
828
|
+
info.appendPosition = fileInfo.metadata.lineCount;
|
|
829
|
+
}
|
|
830
|
+
// For Excel files
|
|
831
|
+
if (fileInfo.metadata.sheets) {
|
|
832
|
+
info.sheets = fileInfo.metadata.sheets;
|
|
833
|
+
info.isExcelFile = true;
|
|
834
|
+
}
|
|
835
|
+
// For images
|
|
836
|
+
if (fileInfo.metadata.isImage) {
|
|
837
|
+
info.isImage = true;
|
|
838
|
+
}
|
|
839
|
+
// For PDF files
|
|
840
|
+
if (fileInfo.metadata.isPdf) {
|
|
841
|
+
info.isPdf = true;
|
|
842
|
+
info.totalPages = fileInfo.metadata.totalPages;
|
|
843
|
+
if (fileInfo.metadata.title)
|
|
844
|
+
info.title = fileInfo.metadata.title;
|
|
845
|
+
if (fileInfo.metadata.author)
|
|
846
|
+
info.author = fileInfo.metadata.author;
|
|
847
|
+
}
|
|
848
|
+
// For binary files
|
|
849
|
+
if (fileInfo.metadata.isBinary) {
|
|
850
|
+
info.isBinary = true;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
return info;
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Write content to a PDF file.
|
|
857
|
+
* Can create a new PDF from Markdown string, or modify an existing PDF using operations.
|
|
858
|
+
*
|
|
859
|
+
* @param filePath Path to the output PDF file
|
|
860
|
+
* @param content Markdown string (for creation) or array of operations (for modification)
|
|
861
|
+
* @param options Options for PDF generation or modification. For modification, can include `sourcePdf`.
|
|
862
|
+
*/
|
|
863
|
+
export async function writePdf(filePath, content, outputPath, options = {}) {
|
|
864
|
+
const validPath = await validatePath(filePath);
|
|
865
|
+
const fileExtension = getFileExtension(validPath);
|
|
866
|
+
if (typeof content === 'string') {
|
|
867
|
+
// --- PDF CREATION MODE ---
|
|
868
|
+
capture('server_write_pdf', {
|
|
869
|
+
fileExtension: fileExtension,
|
|
870
|
+
contentLength: content.length,
|
|
871
|
+
mode: 'create'
|
|
872
|
+
});
|
|
873
|
+
const pdfBuffer = await parseMarkdownToPdf(content, options);
|
|
874
|
+
// Use outputPath if provided, otherwise overwrite input file
|
|
875
|
+
const targetPath = outputPath ? await validatePath(outputPath) : validPath;
|
|
876
|
+
await fs.writeFile(targetPath, pdfBuffer);
|
|
877
|
+
}
|
|
878
|
+
else if (Array.isArray(content)) {
|
|
879
|
+
// Use outputPath if provided, otherwise overwrite input file
|
|
880
|
+
const targetPath = outputPath ? await validatePath(outputPath) : validPath;
|
|
881
|
+
const operations = [];
|
|
882
|
+
// Validate paths in operations
|
|
883
|
+
for (const o of content) {
|
|
884
|
+
if (o.type === 'insert') {
|
|
885
|
+
if (o.sourcePdfPath) {
|
|
886
|
+
o.sourcePdfPath = await validatePath(o.sourcePdfPath);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
operations.push(o);
|
|
890
|
+
}
|
|
891
|
+
capture('server_write_pdf', {
|
|
892
|
+
fileExtension: fileExtension,
|
|
893
|
+
operationCount: operations.length,
|
|
894
|
+
mode: 'modify',
|
|
895
|
+
deleteCount: operations.filter(op => op.type === 'delete').length,
|
|
896
|
+
insertCount: operations.filter(op => op.type === 'insert').length
|
|
897
|
+
});
|
|
898
|
+
// Perform the PDF editing
|
|
899
|
+
const modifiedPdfBuffer = await editPdf(validPath, operations);
|
|
900
|
+
// Write the modified PDF to the output path
|
|
901
|
+
await fs.writeFile(targetPath, modifiedPdfBuffer);
|
|
902
|
+
}
|
|
903
|
+
else {
|
|
904
|
+
throw new Error('Invalid content type for writePdf. Expected string (markdown) or array of operations.');
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
const execFileAsync = promisify(execFile);
|
|
908
|
+
const DEFAULT_EDITOR_NEGATIVE_CACHE_MS = 5 * 60 * 1000;
|
|
909
|
+
const defaultEditorCache = new Map();
|
|
910
|
+
function escapeAppleScriptString(value) {
|
|
911
|
+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
912
|
+
}
|
|
913
|
+
export async function getDefaultEditorMetadata(filePath) {
|
|
914
|
+
if (os.platform() !== 'darwin') {
|
|
915
|
+
return {};
|
|
916
|
+
}
|
|
917
|
+
let cacheKey = '';
|
|
918
|
+
try {
|
|
919
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
920
|
+
cacheKey = extension || path.basename(filePath).toLowerCase();
|
|
921
|
+
const cached = defaultEditorCache.get(cacheKey);
|
|
922
|
+
if (cached) {
|
|
923
|
+
if (!cached.expiresAt || cached.expiresAt > Date.now()) {
|
|
924
|
+
return cached.metadata;
|
|
925
|
+
}
|
|
926
|
+
defaultEditorCache.delete(cacheKey);
|
|
927
|
+
}
|
|
928
|
+
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`;
|
|
929
|
+
const { stdout } = await execFileAsync('osascript', ['-e', script], { timeout: 12000 });
|
|
930
|
+
const lines = stdout.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
931
|
+
const defaultEditorName = lines[lines.length - 2]?.replace(/\.app$/i, '') ?? '';
|
|
932
|
+
const defaultEditorPath = lines[lines.length - 1] ?? '';
|
|
933
|
+
if (defaultEditorName && defaultEditorPath.startsWith('/')) {
|
|
934
|
+
const metadata = { defaultEditorName, defaultEditorPath };
|
|
935
|
+
defaultEditorCache.set(cacheKey, { metadata });
|
|
936
|
+
return metadata;
|
|
937
|
+
}
|
|
938
|
+
defaultEditorCache.set(cacheKey, { metadata: {}, expiresAt: Date.now() + DEFAULT_EDITOR_NEGATIVE_CACHE_MS });
|
|
939
|
+
}
|
|
940
|
+
catch {
|
|
941
|
+
if (cacheKey) {
|
|
942
|
+
defaultEditorCache.set(cacheKey, { metadata: {}, expiresAt: Date.now() + DEFAULT_EDITOR_NEGATIVE_CACHE_MS });
|
|
943
|
+
}
|
|
944
|
+
// Generic UI fallback is good enough if detection fails.
|
|
945
|
+
}
|
|
946
|
+
return {};
|
|
947
|
+
}
|