apple-notes-mcp 2.5.7 → 2.5.8
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/README.md +8 -5
- package/build/index.js +42669 -1077
- package/package.json +3 -3
- package/build/index.test.js +0 -446
- package/build/services/__fixtures__/notesNormalizedHtml.js +0 -32
- package/build/services/appleNotesManager.js +0 -2634
- package/build/services/appleNotesManager.test.js +0 -2416
- package/build/services/attachmentSave.test.js +0 -85
- package/build/services/fileConfig.js +0 -51
- package/build/services/fileConfig.test.js +0 -48
- package/build/services/notesHtmlMarkdown.test.js +0 -55
- package/build/tools/doctor.js +0 -50
- package/build/tools/doctor.test.js +0 -42
- package/build/tools/resourcesAndPrompts.js +0 -70
- package/build/tools/resourcesAndPrompts.test.js +0 -63
- package/build/types.js +0 -13
- package/build/utils/applescript.js +0 -421
- package/build/utils/applescript.test.js +0 -342
- package/build/utils/attachmentFs.js +0 -97
- package/build/utils/attachmentFs.test.js +0 -69
- package/build/utils/checklistParser.js +0 -259
- package/build/utils/checklistParser.test.js +0 -230
- package/build/utils/contentWarnings.js +0 -44
- package/build/utils/contentWarnings.test.js +0 -52
- package/build/utils/hashtags.js +0 -56
- package/build/utils/hashtags.test.js +0 -45
- package/build/utils/jxa.js +0 -139
- package/build/utils/jxa.test.js +0 -134
- package/build/utils/noteMetadata.js +0 -135
- package/build/utils/noteMetadata.test.js +0 -106
- package/build/utils/protobuf.js +0 -151
- package/build/utils/protobuf.test.js +0 -138
- package/build/utils/syncDetection.js +0 -242
- package/build/utils/syncDetection.test.js +0 -228
|
@@ -1,342 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for AppleScript execution utilities
|
|
3
|
-
*
|
|
4
|
-
* These tests mock the child_process.execSync function to avoid
|
|
5
|
-
* requiring actual AppleScript execution during testing.
|
|
6
|
-
*/
|
|
7
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
8
|
-
import { execSync } from "child_process";
|
|
9
|
-
import { executeAppleScript } from "./applescript.js";
|
|
10
|
-
// Mock the child_process module
|
|
11
|
-
vi.mock("child_process", () => ({
|
|
12
|
-
execSync: vi.fn(),
|
|
13
|
-
spawnSync: vi.fn(() => ({ error: null })), // Mock sleep to return immediately
|
|
14
|
-
}));
|
|
15
|
-
const mockExecSync = vi.mocked(execSync);
|
|
16
|
-
describe("executeAppleScript", () => {
|
|
17
|
-
beforeEach(() => {
|
|
18
|
-
vi.clearAllMocks();
|
|
19
|
-
});
|
|
20
|
-
describe("successful execution", () => {
|
|
21
|
-
it("returns success result with trimmed output", () => {
|
|
22
|
-
// Arrange: Mock a successful AppleScript execution
|
|
23
|
-
mockExecSync.mockReturnValue(" Note Title \n");
|
|
24
|
-
// Act: Execute a simple script
|
|
25
|
-
const result = executeAppleScript('tell app "Notes" to get name of note 1');
|
|
26
|
-
// Assert: Output should be trimmed
|
|
27
|
-
expect(result.success).toBe(true);
|
|
28
|
-
expect(result.output).toBe("Note Title");
|
|
29
|
-
expect(result.error).toBeUndefined();
|
|
30
|
-
});
|
|
31
|
-
it("preserves newlines within the script for AppleScript syntax", () => {
|
|
32
|
-
mockExecSync.mockReturnValue("success");
|
|
33
|
-
// Multi-line AppleScript with tell blocks
|
|
34
|
-
const script = `
|
|
35
|
-
tell application "Notes"
|
|
36
|
-
tell account "iCloud"
|
|
37
|
-
get notes
|
|
38
|
-
end tell
|
|
39
|
-
end tell
|
|
40
|
-
`;
|
|
41
|
-
executeAppleScript(script);
|
|
42
|
-
// Verify the script was passed with newlines preserved
|
|
43
|
-
const calledCommand = mockExecSync.mock.calls[0][0];
|
|
44
|
-
expect(calledCommand).toContain("tell application");
|
|
45
|
-
expect(calledCommand).toContain("end tell");
|
|
46
|
-
});
|
|
47
|
-
it("escapes single quotes in the script for shell safety", () => {
|
|
48
|
-
mockExecSync.mockReturnValue("content");
|
|
49
|
-
// Script containing a single quote (e.g., in a note title)
|
|
50
|
-
executeAppleScript('get note "Rob\'s Notes"');
|
|
51
|
-
// Verify the quote was escaped for shell
|
|
52
|
-
const calledCommand = mockExecSync.mock.calls[0][0];
|
|
53
|
-
expect(calledCommand).toContain("Rob'\\''s");
|
|
54
|
-
});
|
|
55
|
-
});
|
|
56
|
-
describe("hardened executor (#16/#17)", () => {
|
|
57
|
-
afterEach(() => {
|
|
58
|
-
delete process.env.APPLE_NOTES_MCP_MAX_BUFFER;
|
|
59
|
-
});
|
|
60
|
-
it("wraps the script in `with timeout` so Notes.app aborts cleanly", () => {
|
|
61
|
-
mockExecSync.mockReturnValue("ok");
|
|
62
|
-
executeAppleScript("get name of notes", { timeoutMs: 30000 });
|
|
63
|
-
const cmd = mockExecSync.mock.calls[0][0];
|
|
64
|
-
expect(cmd).toContain("with timeout of");
|
|
65
|
-
expect(cmd).toContain("end timeout");
|
|
66
|
-
// 30s process timeout − 5s headroom = 25s script timeout
|
|
67
|
-
expect(cmd).toContain("with timeout of 25 seconds");
|
|
68
|
-
});
|
|
69
|
-
it("passes SIGKILL and a large maxBuffer to execSync", () => {
|
|
70
|
-
mockExecSync.mockReturnValue("ok");
|
|
71
|
-
executeAppleScript("get name of notes");
|
|
72
|
-
const opts = mockExecSync.mock.calls[0][1];
|
|
73
|
-
expect(opts.killSignal).toBe("SIGKILL");
|
|
74
|
-
expect(opts.maxBuffer).toBe(64 * 1024 * 1024);
|
|
75
|
-
});
|
|
76
|
-
it("honors APPLE_NOTES_MCP_MAX_BUFFER override", () => {
|
|
77
|
-
process.env.APPLE_NOTES_MCP_MAX_BUFFER = "1048576";
|
|
78
|
-
mockExecSync.mockReturnValue("ok");
|
|
79
|
-
executeAppleScript("get name of notes");
|
|
80
|
-
const opts = mockExecSync.mock.calls[0][1];
|
|
81
|
-
expect(opts.maxBuffer).toBe(1048576);
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
describe("error handling", () => {
|
|
85
|
-
it("returns error result when execution fails", () => {
|
|
86
|
-
// Arrange: Mock an AppleScript execution failure
|
|
87
|
-
mockExecSync.mockImplementation(() => {
|
|
88
|
-
throw new Error("execution error: Can't get note. (-1728)");
|
|
89
|
-
});
|
|
90
|
-
// Act: Try to execute a script that will fail
|
|
91
|
-
const result = executeAppleScript('get note "Nonexistent"');
|
|
92
|
-
// Assert: Should return structured error
|
|
93
|
-
expect(result.success).toBe(false);
|
|
94
|
-
expect(result.output).toBe("");
|
|
95
|
-
expect(result.error).toBeDefined();
|
|
96
|
-
});
|
|
97
|
-
it("parses execution error messages cleanly", () => {
|
|
98
|
-
mockExecSync.mockImplementation(() => {
|
|
99
|
-
throw new Error("execution error: Note not found (-1728)");
|
|
100
|
-
});
|
|
101
|
-
const result = executeAppleScript("get note 1");
|
|
102
|
-
// Should extract the meaningful part of the error
|
|
103
|
-
expect(result.error).toBe("Note not found");
|
|
104
|
-
});
|
|
105
|
-
it("handles 'not found' error patterns with user-friendly message", () => {
|
|
106
|
-
mockExecSync.mockImplementation(() => {
|
|
107
|
-
throw new Error('Can\'t get note "Missing".');
|
|
108
|
-
});
|
|
109
|
-
const result = executeAppleScript('get note "Missing"');
|
|
110
|
-
expect(result.error).toContain("not found");
|
|
111
|
-
expect(result.error).toContain("Missing");
|
|
112
|
-
expect(result.error).toContain("case-sensitive"); // Includes helpful hint
|
|
113
|
-
});
|
|
114
|
-
it("provides helpful message for permission errors", () => {
|
|
115
|
-
mockExecSync.mockImplementation(() => {
|
|
116
|
-
throw new Error("execution error: Not authorized to send Apple events (-1743)");
|
|
117
|
-
});
|
|
118
|
-
const result = executeAppleScript("test");
|
|
119
|
-
expect(result.error).toContain("Permission denied");
|
|
120
|
-
expect(result.error).toContain("System Preferences");
|
|
121
|
-
});
|
|
122
|
-
it("provides helpful message for folder not found", () => {
|
|
123
|
-
mockExecSync.mockImplementation(() => {
|
|
124
|
-
throw new Error('Can\'t get folder "Work".');
|
|
125
|
-
});
|
|
126
|
-
const result = executeAppleScript("test");
|
|
127
|
-
expect(result.error).toContain("Work");
|
|
128
|
-
expect(result.error).toContain("not found");
|
|
129
|
-
expect(result.error).toContain("list-folders");
|
|
130
|
-
});
|
|
131
|
-
it("provides helpful message for account not found", () => {
|
|
132
|
-
mockExecSync.mockImplementation(() => {
|
|
133
|
-
throw new Error('Can\'t get account "Gmail".');
|
|
134
|
-
});
|
|
135
|
-
const result = executeAppleScript("test");
|
|
136
|
-
expect(result.error).toContain("Gmail");
|
|
137
|
-
expect(result.error).toContain("not found");
|
|
138
|
-
expect(result.error).toContain("list-accounts");
|
|
139
|
-
});
|
|
140
|
-
it("handles non-Error exceptions gracefully", () => {
|
|
141
|
-
mockExecSync.mockImplementation(() => {
|
|
142
|
-
throw "string error"; // Some code throws strings
|
|
143
|
-
});
|
|
144
|
-
const result = executeAppleScript("some script");
|
|
145
|
-
expect(result.success).toBe(false);
|
|
146
|
-
expect(result.error).toBe("string error");
|
|
147
|
-
});
|
|
148
|
-
it("handles unknown error types", () => {
|
|
149
|
-
mockExecSync.mockImplementation(() => {
|
|
150
|
-
throw { weird: "object" }; // Unusual but possible
|
|
151
|
-
});
|
|
152
|
-
const result = executeAppleScript("some script");
|
|
153
|
-
expect(result.success).toBe(false);
|
|
154
|
-
expect(result.error).toBe("AppleScript execution failed with unknown error");
|
|
155
|
-
});
|
|
156
|
-
});
|
|
157
|
-
describe("input validation", () => {
|
|
158
|
-
it("returns error for empty script", () => {
|
|
159
|
-
const result = executeAppleScript("");
|
|
160
|
-
expect(result.success).toBe(false);
|
|
161
|
-
expect(result.error).toBe("Cannot execute empty AppleScript");
|
|
162
|
-
expect(mockExecSync).not.toHaveBeenCalled();
|
|
163
|
-
});
|
|
164
|
-
it("returns error for whitespace-only script", () => {
|
|
165
|
-
const result = executeAppleScript(" \n\t ");
|
|
166
|
-
expect(result.success).toBe(false);
|
|
167
|
-
expect(result.error).toBe("Cannot execute empty AppleScript");
|
|
168
|
-
expect(mockExecSync).not.toHaveBeenCalled();
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
describe("execution options", () => {
|
|
172
|
-
it("uses default 30 second timeout", () => {
|
|
173
|
-
mockExecSync.mockReturnValue("ok");
|
|
174
|
-
executeAppleScript("test");
|
|
175
|
-
const options = mockExecSync.mock.calls[0][1];
|
|
176
|
-
expect(options.timeout).toBe(30000); // 30 second default timeout
|
|
177
|
-
});
|
|
178
|
-
it("allows custom timeout via options", () => {
|
|
179
|
-
mockExecSync.mockReturnValue("ok");
|
|
180
|
-
executeAppleScript("test", { timeoutMs: 60000 });
|
|
181
|
-
const options = mockExecSync.mock.calls[0][1];
|
|
182
|
-
expect(options.timeout).toBe(60000); // Custom timeout
|
|
183
|
-
});
|
|
184
|
-
it("uses UTF-8 encoding for output", () => {
|
|
185
|
-
mockExecSync.mockReturnValue("日本語テスト");
|
|
186
|
-
const result = executeAppleScript("test");
|
|
187
|
-
expect(result.output).toBe("日本語テスト");
|
|
188
|
-
const options = mockExecSync.mock.calls[0][1];
|
|
189
|
-
expect(options.encoding).toBe("utf8");
|
|
190
|
-
});
|
|
191
|
-
});
|
|
192
|
-
describe("timeout handling", () => {
|
|
193
|
-
it("returns specific error message on timeout", () => {
|
|
194
|
-
// Simulate a timeout error (Node.js sets killed=true and signal=SIGTERM)
|
|
195
|
-
const timeoutError = new Error("Command failed: SIGTERM");
|
|
196
|
-
timeoutError.killed = true;
|
|
197
|
-
timeoutError.signal = "SIGTERM";
|
|
198
|
-
mockExecSync.mockImplementation(() => {
|
|
199
|
-
throw timeoutError;
|
|
200
|
-
});
|
|
201
|
-
const result = executeAppleScript("test");
|
|
202
|
-
expect(result.success).toBe(false);
|
|
203
|
-
expect(result.error).toContain("timed out after 30 seconds");
|
|
204
|
-
expect(result.error).toContain("Notes.app may be unresponsive");
|
|
205
|
-
});
|
|
206
|
-
it("includes custom timeout value in error message", () => {
|
|
207
|
-
const timeoutError = new Error("Command failed: SIGTERM");
|
|
208
|
-
timeoutError.killed = true;
|
|
209
|
-
timeoutError.signal = "SIGTERM";
|
|
210
|
-
mockExecSync.mockImplementation(() => {
|
|
211
|
-
throw timeoutError;
|
|
212
|
-
});
|
|
213
|
-
const result = executeAppleScript("test", { timeoutMs: 60000 });
|
|
214
|
-
expect(result.error).toContain("timed out after 60 seconds");
|
|
215
|
-
});
|
|
216
|
-
});
|
|
217
|
-
describe("retry logic", () => {
|
|
218
|
-
it("does not retry by default (maxRetries=1)", () => {
|
|
219
|
-
mockExecSync.mockImplementation(() => {
|
|
220
|
-
throw new Error("Notes.app is not responding");
|
|
221
|
-
});
|
|
222
|
-
executeAppleScript("test");
|
|
223
|
-
// Only one attempt with default settings
|
|
224
|
-
expect(mockExecSync).toHaveBeenCalledTimes(1);
|
|
225
|
-
});
|
|
226
|
-
it("retries on transient errors when maxRetries > 1", () => {
|
|
227
|
-
let callCount = 0;
|
|
228
|
-
mockExecSync.mockImplementation(() => {
|
|
229
|
-
callCount++;
|
|
230
|
-
if (callCount < 3) {
|
|
231
|
-
throw new Error("Notes.app is not responding");
|
|
232
|
-
}
|
|
233
|
-
return "success";
|
|
234
|
-
});
|
|
235
|
-
const result = executeAppleScript("test", { maxRetries: 3, retryDelayMs: 1 });
|
|
236
|
-
expect(result.success).toBe(true);
|
|
237
|
-
expect(result.output).toBe("success");
|
|
238
|
-
expect(mockExecSync).toHaveBeenCalledTimes(3);
|
|
239
|
-
});
|
|
240
|
-
it("does not retry on non-transient errors", () => {
|
|
241
|
-
mockExecSync.mockImplementation(() => {
|
|
242
|
-
throw new Error('Can\'t get note "Missing"');
|
|
243
|
-
});
|
|
244
|
-
executeAppleScript("test", { maxRetries: 3, retryDelayMs: 1 });
|
|
245
|
-
// Should not retry for "note not found" errors
|
|
246
|
-
expect(mockExecSync).toHaveBeenCalledTimes(1);
|
|
247
|
-
});
|
|
248
|
-
it("retries on timeout errors", () => {
|
|
249
|
-
let callCount = 0;
|
|
250
|
-
mockExecSync.mockImplementation(() => {
|
|
251
|
-
callCount++;
|
|
252
|
-
if (callCount < 2) {
|
|
253
|
-
const timeoutError = new Error("SIGTERM");
|
|
254
|
-
timeoutError.killed = true;
|
|
255
|
-
timeoutError.signal = "SIGTERM";
|
|
256
|
-
throw timeoutError;
|
|
257
|
-
}
|
|
258
|
-
return "success after retry";
|
|
259
|
-
});
|
|
260
|
-
const result = executeAppleScript("test", { maxRetries: 3, retryDelayMs: 1 });
|
|
261
|
-
expect(result.success).toBe(true);
|
|
262
|
-
expect(result.output).toBe("success after retry");
|
|
263
|
-
expect(mockExecSync).toHaveBeenCalledTimes(2);
|
|
264
|
-
});
|
|
265
|
-
it("retries on 'connection invalid' errors", () => {
|
|
266
|
-
let callCount = 0;
|
|
267
|
-
mockExecSync.mockImplementation(() => {
|
|
268
|
-
callCount++;
|
|
269
|
-
if (callCount < 2) {
|
|
270
|
-
throw new Error("connection is invalid");
|
|
271
|
-
}
|
|
272
|
-
return "recovered";
|
|
273
|
-
});
|
|
274
|
-
const result = executeAppleScript("test", { maxRetries: 3, retryDelayMs: 1 });
|
|
275
|
-
expect(result.success).toBe(true);
|
|
276
|
-
expect(mockExecSync).toHaveBeenCalledTimes(2);
|
|
277
|
-
});
|
|
278
|
-
it("returns last error after all retries exhausted", () => {
|
|
279
|
-
mockExecSync.mockImplementation(() => {
|
|
280
|
-
throw new Error("Notes.app is not responding");
|
|
281
|
-
});
|
|
282
|
-
const result = executeAppleScript("test", { maxRetries: 3, retryDelayMs: 1 });
|
|
283
|
-
expect(result.success).toBe(false);
|
|
284
|
-
expect(result.error).toContain("not responding");
|
|
285
|
-
expect(mockExecSync).toHaveBeenCalledTimes(3);
|
|
286
|
-
});
|
|
287
|
-
it("retries on 'timed out' errors", () => {
|
|
288
|
-
let callCount = 0;
|
|
289
|
-
mockExecSync.mockImplementation(() => {
|
|
290
|
-
callCount++;
|
|
291
|
-
if (callCount < 2) {
|
|
292
|
-
throw new Error("operation timed out");
|
|
293
|
-
}
|
|
294
|
-
return "recovered";
|
|
295
|
-
});
|
|
296
|
-
const result = executeAppleScript("test", { maxRetries: 3, retryDelayMs: 1 });
|
|
297
|
-
expect(result.success).toBe(true);
|
|
298
|
-
expect(mockExecSync).toHaveBeenCalledTimes(2);
|
|
299
|
-
});
|
|
300
|
-
it("retries on 'lost connection' errors", () => {
|
|
301
|
-
let callCount = 0;
|
|
302
|
-
mockExecSync.mockImplementation(() => {
|
|
303
|
-
callCount++;
|
|
304
|
-
if (callCount < 2) {
|
|
305
|
-
throw new Error("lost connection to Notes.app");
|
|
306
|
-
}
|
|
307
|
-
return "recovered";
|
|
308
|
-
});
|
|
309
|
-
const result = executeAppleScript("test", { maxRetries: 3, retryDelayMs: 1 });
|
|
310
|
-
expect(result.success).toBe(true);
|
|
311
|
-
expect(mockExecSync).toHaveBeenCalledTimes(2);
|
|
312
|
-
});
|
|
313
|
-
it("retries on 'busy' errors", () => {
|
|
314
|
-
let callCount = 0;
|
|
315
|
-
mockExecSync.mockImplementation(() => {
|
|
316
|
-
callCount++;
|
|
317
|
-
if (callCount < 2) {
|
|
318
|
-
throw new Error("Notes.app is busy");
|
|
319
|
-
}
|
|
320
|
-
return "recovered";
|
|
321
|
-
});
|
|
322
|
-
const result = executeAppleScript("test", { maxRetries: 3, retryDelayMs: 1 });
|
|
323
|
-
expect(result.success).toBe(true);
|
|
324
|
-
expect(mockExecSync).toHaveBeenCalledTimes(2);
|
|
325
|
-
});
|
|
326
|
-
it("uses exponential backoff between retries", () => {
|
|
327
|
-
let execCallCount = 0;
|
|
328
|
-
// Fails first 3 times, succeeds on 4th attempt
|
|
329
|
-
mockExecSync.mockImplementation(() => {
|
|
330
|
-
execCallCount++;
|
|
331
|
-
if (execCallCount <= 3) {
|
|
332
|
-
throw new Error("Notes.app is not responding");
|
|
333
|
-
}
|
|
334
|
-
return "success";
|
|
335
|
-
});
|
|
336
|
-
// With retryDelayMs=100, delays should be: 100ms, 200ms, 400ms
|
|
337
|
-
const result = executeAppleScript("test", { maxRetries: 4, retryDelayMs: 100 });
|
|
338
|
-
expect(result.success).toBe(true);
|
|
339
|
-
expect(mockExecSync).toHaveBeenCalledTimes(4);
|
|
340
|
-
});
|
|
341
|
-
});
|
|
342
|
-
});
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Filesystem helpers for saving / fetching note attachments (#27).
|
|
3
|
-
*
|
|
4
|
-
* Notes.app exports an attachment to a path via AppleScript `save`. These helpers
|
|
5
|
-
* keep that safe (no writing outside sensible roots, no path traversal) and
|
|
6
|
-
* provide a base64 read for the fetch-attachment tool.
|
|
7
|
-
*
|
|
8
|
-
* @module utils/attachmentFs
|
|
9
|
-
*/
|
|
10
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "fs";
|
|
11
|
-
import { isAbsolute, resolve, sep } from "path";
|
|
12
|
-
import { homedir, tmpdir } from "os";
|
|
13
|
-
/** Roots an attachment may be written to. */
|
|
14
|
-
export function allowedSaveRoots() {
|
|
15
|
-
return [resolve(homedir()), resolve(tmpdir()), "/Volumes", "/private/var/folders", "/tmp"];
|
|
16
|
-
}
|
|
17
|
-
/**
|
|
18
|
-
* Validate a user-supplied destination path. Returns the resolved absolute path,
|
|
19
|
-
* or throws if it is relative or escapes the allowed roots.
|
|
20
|
-
*/
|
|
21
|
-
export function assertSafeSavePath(p, roots = allowedSaveRoots()) {
|
|
22
|
-
if (!p || !p.trim())
|
|
23
|
-
throw new Error("A destination path is required.");
|
|
24
|
-
if (!isAbsolute(p))
|
|
25
|
-
throw new Error(`Destination path must be absolute: "${p}"`);
|
|
26
|
-
const abs = resolve(p);
|
|
27
|
-
const ok = roots.some((r) => abs === r || abs.startsWith(r.endsWith(sep) ? r : r + sep));
|
|
28
|
-
if (!ok) {
|
|
29
|
-
throw new Error(`Refusing to write outside allowed locations (home, temp, /Volumes): "${abs}"`);
|
|
30
|
-
}
|
|
31
|
-
return abs;
|
|
32
|
-
}
|
|
33
|
-
/**
|
|
34
|
-
* Default upper bound on an attachment that `fetch-attachment` will base64-encode
|
|
35
|
-
* into a single MCP response. `readFileSync` loads the whole file into memory and
|
|
36
|
-
* base64 grows it ~33%, so an unbounded read of a multi-GB attachment (video,
|
|
37
|
-
* disk image) could exhaust memory. 25 MB is generous for the inline-fetch use
|
|
38
|
-
* case (docs, images, PDFs); larger attachments should be exported to disk with
|
|
39
|
-
* `save-attachment` instead. Overridable via APPLE_NOTES_MCP_MAX_ATTACHMENT_BYTES.
|
|
40
|
-
*/
|
|
41
|
-
const DEFAULT_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
|
|
42
|
-
/** Resolve the configured max attachment size (bytes) for inline base64 fetch. */
|
|
43
|
-
export function maxAttachmentBytes(env = process.env) {
|
|
44
|
-
const raw = env.APPLE_NOTES_MCP_MAX_ATTACHMENT_BYTES;
|
|
45
|
-
if (raw !== undefined) {
|
|
46
|
-
const n = Number(raw);
|
|
47
|
-
if (Number.isFinite(n) && n > 0)
|
|
48
|
-
return n;
|
|
49
|
-
}
|
|
50
|
-
return DEFAULT_MAX_ATTACHMENT_BYTES;
|
|
51
|
-
}
|
|
52
|
-
/** Read a file as base64. */
|
|
53
|
-
export function readFileBase64(p) {
|
|
54
|
-
return readFileSync(p).toString("base64");
|
|
55
|
-
}
|
|
56
|
-
/**
|
|
57
|
-
* Read a file as base64, refusing files larger than `maxBytes`.
|
|
58
|
-
*
|
|
59
|
-
* Guards `fetch-attachment` against unbounded in-memory reads: the size is
|
|
60
|
-
* checked from filesystem metadata BEFORE the file is read, so an oversized
|
|
61
|
-
* attachment is rejected with a clear error instead of loading it (and its
|
|
62
|
-
* ~33%-larger base64) into memory. (`APPLE_NOTES_MCP_MAX_BUFFER` does not apply
|
|
63
|
-
* to `readFileSync`.)
|
|
64
|
-
*
|
|
65
|
-
* @throws if the file exceeds `maxBytes`
|
|
66
|
-
*/
|
|
67
|
-
export function readFileBase64Capped(p, maxBytes = maxAttachmentBytes()) {
|
|
68
|
-
const size = fileSize(p);
|
|
69
|
-
if (size > maxBytes) {
|
|
70
|
-
throw new Error(`Attachment is ${size} bytes, exceeding the ${maxBytes}-byte fetch limit ` +
|
|
71
|
-
`(APPLE_NOTES_MCP_MAX_ATTACHMENT_BYTES). Use save-attachment to export it to disk instead.`);
|
|
72
|
-
}
|
|
73
|
-
return readFileBase64(p);
|
|
74
|
-
}
|
|
75
|
-
/** Byte size of a file (0 if missing). */
|
|
76
|
-
export function fileSize(p) {
|
|
77
|
-
try {
|
|
78
|
-
return statSync(p).size;
|
|
79
|
-
}
|
|
80
|
-
catch {
|
|
81
|
-
return 0;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
/** Make a private temp dir for a one-shot attachment export; caller cleans up. */
|
|
85
|
-
export function makeTempDir() {
|
|
86
|
-
return mkdtempSync(resolve(tmpdir(), "apple-notes-att-"));
|
|
87
|
-
}
|
|
88
|
-
/** Remove a temp dir tree, ignoring errors. */
|
|
89
|
-
export function cleanupTempDir(dir) {
|
|
90
|
-
try {
|
|
91
|
-
if (existsSync(dir))
|
|
92
|
-
rmSync(dir, { recursive: true, force: true });
|
|
93
|
-
}
|
|
94
|
-
catch {
|
|
95
|
-
/* best-effort */
|
|
96
|
-
}
|
|
97
|
-
}
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
-
import { writeFileSync, existsSync, mkdtempSync } from "fs";
|
|
3
|
-
import { homedir, tmpdir } from "os";
|
|
4
|
-
import { join } from "path";
|
|
5
|
-
import { assertSafeSavePath, readFileBase64, readFileBase64Capped, maxAttachmentBytes, fileSize, makeTempDir, cleanupTempDir, allowedSaveRoots, } from "../utils/attachmentFs.js";
|
|
6
|
-
const dirs = [];
|
|
7
|
-
afterEach(() => dirs.splice(0).forEach(cleanupTempDir));
|
|
8
|
-
describe("assertSafeSavePath (#27)", () => {
|
|
9
|
-
it("accepts absolute paths under the temp dir and home dir", () => {
|
|
10
|
-
const p = join(tmpdir(), "x.png");
|
|
11
|
-
expect(assertSafeSavePath(p)).toBe(p);
|
|
12
|
-
const h = join(homedir(), "Downloads", "x.png");
|
|
13
|
-
expect(assertSafeSavePath(h)).toBe(h);
|
|
14
|
-
});
|
|
15
|
-
it("rejects empty, relative, and out-of-root paths", () => {
|
|
16
|
-
expect(() => assertSafeSavePath("")).toThrow(/required/);
|
|
17
|
-
expect(() => assertSafeSavePath("relative/x.png")).toThrow(/absolute/);
|
|
18
|
-
expect(() => assertSafeSavePath("/etc/passwd")).toThrow(/outside allowed/);
|
|
19
|
-
});
|
|
20
|
-
it("blocks traversal that escapes an allowed root", () => {
|
|
21
|
-
expect(() => assertSafeSavePath(join(tmpdir(), "..", "..", "etc", "x"))).toThrow(/outside allowed/);
|
|
22
|
-
});
|
|
23
|
-
it("exposes the allowed roots", () => {
|
|
24
|
-
expect(allowedSaveRoots()).toEqual(expect.arrayContaining(["/Volumes"]));
|
|
25
|
-
});
|
|
26
|
-
});
|
|
27
|
-
describe("base64 / size / temp helpers (#27)", () => {
|
|
28
|
-
it("reads a file as base64 and reports its size", () => {
|
|
29
|
-
const dir = mkdtempSync(join(tmpdir(), "anatt-"));
|
|
30
|
-
dirs.push(dir);
|
|
31
|
-
const f = join(dir, "f.bin");
|
|
32
|
-
writeFileSync(f, Buffer.from("hello"));
|
|
33
|
-
expect(readFileBase64(f)).toBe(Buffer.from("hello").toString("base64"));
|
|
34
|
-
expect(fileSize(f)).toBe(5);
|
|
35
|
-
});
|
|
36
|
-
it("fileSize returns 0 for a missing file", () => {
|
|
37
|
-
expect(fileSize(join(tmpdir(), "definitely-missing-xyz.bin"))).toBe(0);
|
|
38
|
-
});
|
|
39
|
-
it("makeTempDir creates a dir and cleanupTempDir removes it (idempotent)", () => {
|
|
40
|
-
const dir = makeTempDir();
|
|
41
|
-
expect(existsSync(dir)).toBe(true);
|
|
42
|
-
cleanupTempDir(dir);
|
|
43
|
-
expect(existsSync(dir)).toBe(false);
|
|
44
|
-
expect(() => cleanupTempDir(dir)).not.toThrow();
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
describe("readFileBase64Capped / maxAttachmentBytes (size guard)", () => {
|
|
48
|
-
it("reads files at or under the cap", () => {
|
|
49
|
-
const dir = mkdtempSync(join(tmpdir(), "anatt-"));
|
|
50
|
-
dirs.push(dir);
|
|
51
|
-
const f = join(dir, "ok.bin");
|
|
52
|
-
writeFileSync(f, Buffer.from("hello"));
|
|
53
|
-
expect(readFileBase64Capped(f, 1024)).toBe(Buffer.from("hello").toString("base64"));
|
|
54
|
-
});
|
|
55
|
-
it("throws (without reading) when the file exceeds the cap", () => {
|
|
56
|
-
const dir = mkdtempSync(join(tmpdir(), "anatt-"));
|
|
57
|
-
dirs.push(dir);
|
|
58
|
-
const f = join(dir, "big.bin");
|
|
59
|
-
writeFileSync(f, Buffer.alloc(2048));
|
|
60
|
-
expect(() => readFileBase64Capped(f, 1024)).toThrow(/exceeding the 1024-byte fetch limit/);
|
|
61
|
-
});
|
|
62
|
-
it("maxAttachmentBytes honors APPLE_NOTES_MCP_MAX_ATTACHMENT_BYTES and falls back to a sane default", () => {
|
|
63
|
-
expect(maxAttachmentBytes({ APPLE_NOTES_MCP_MAX_ATTACHMENT_BYTES: "12345" })).toBe(12345);
|
|
64
|
-
// Invalid / non-positive values fall back to the default (25 MB).
|
|
65
|
-
expect(maxAttachmentBytes({ APPLE_NOTES_MCP_MAX_ATTACHMENT_BYTES: "0" })).toBe(25 * 1024 * 1024);
|
|
66
|
-
expect(maxAttachmentBytes({ APPLE_NOTES_MCP_MAX_ATTACHMENT_BYTES: "nope" })).toBe(25 * 1024 * 1024);
|
|
67
|
-
expect(maxAttachmentBytes({})).toBe(25 * 1024 * 1024);
|
|
68
|
-
});
|
|
69
|
-
});
|