apple-tools-mcp 2.0.0 → 2.0.1
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 +31 -14
- package/index.js +4 -0
- package/lib/appleScript.js +129 -5
- package/lib/calendarWrite.js +796 -23
- package/lib/contactsWrite.js +1 -1
- package/lib/eventKitSession.js +369 -0
- package/lib/mailWrite.js +39 -3
- package/lib/messagesWrite.js +35 -3
- package/lib/shell.js +23 -5
- package/lib/writeGuards.js +8 -0
- package/lib/writeRouting.js +2 -2
- package/lib/writeTools.js +18 -3
- package/package.json +1 -1
- package/scripts/smoke-writes.js +184 -16
package/lib/contactsWrite.js
CHANGED
|
@@ -76,7 +76,7 @@ function childLines(items, kind, personVar) {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
function failure(action, summary, result, secrets = []) {
|
|
79
|
-
if (result.kind === "tcc") {
|
|
79
|
+
if (result.kind === "tcc" || result.kind === "timeout") {
|
|
80
80
|
return `${action} failed — attempted to ${summary}. ${CONTACTS_TCC_GUIDANCE}`;
|
|
81
81
|
}
|
|
82
82
|
const raw = String(result.error || "");
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Long-lived EventKit JXA worker.
|
|
3
|
+
*
|
|
4
|
+
* writeOnly (EKAuthorizationStatus = 4) can create an EKEvent and read
|
|
5
|
+
* identifiers off that in-memory object, but cannot re-query via
|
|
6
|
+
* eventWithIdentifier / calendarItemsWithExternalIdentifier — Mini 2cdf44d
|
|
7
|
+
* failed add on a post-save lookup even though create returned ids.
|
|
8
|
+
*
|
|
9
|
+
* The indexer daemon therefore keeps one osascript process around and
|
|
10
|
+
* caches the EKEvent objects from create so edit/remove can call
|
|
11
|
+
* saveEvent / removeEvent without a fetch.
|
|
12
|
+
*
|
|
13
|
+
* stdin/stdout: one JSON object per line. Never interpolates commands
|
|
14
|
+
* into a shell (`spawn`, `shell: false`).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawn } from "child_process";
|
|
18
|
+
import fs from "fs";
|
|
19
|
+
import os from "os";
|
|
20
|
+
import path from "path";
|
|
21
|
+
|
|
22
|
+
let singleton = null;
|
|
23
|
+
|
|
24
|
+
export function setEventKitSession(session) {
|
|
25
|
+
singleton = session || null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getEventKitSession() {
|
|
29
|
+
return singleton;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function shouldStartEventKitSession({
|
|
33
|
+
platform = process.platform,
|
|
34
|
+
env = process.env
|
|
35
|
+
} = {}) {
|
|
36
|
+
if (platform !== "darwin") return false;
|
|
37
|
+
if (env.VITEST) return false;
|
|
38
|
+
if (env.APPLE_TOOLS_EVENTKIT_SESSION === "0") return false;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* JXA worker: create caches the EKEvent; update/remove use the cache.
|
|
44
|
+
* create does not call findEventByIds.
|
|
45
|
+
*/
|
|
46
|
+
export function sessionPaths(home = process.env.HOME || "") {
|
|
47
|
+
const root = home ? path.join(home, ".apple-tools-mcp") : path.join(os.tmpdir(), "atm-eventkit");
|
|
48
|
+
const dir = path.join(root, "eventkit-session");
|
|
49
|
+
return {
|
|
50
|
+
root,
|
|
51
|
+
dir,
|
|
52
|
+
scriptPath: path.join(root, "eventkit-worker.jxa"),
|
|
53
|
+
cmdPath: path.join(dir, "cmd.json"),
|
|
54
|
+
rspPath: path.join(dir, "rsp.json")
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function buildEventKitWorkerScript(helpers, paths = {}) {
|
|
59
|
+
const cmdPath = JSON.stringify(paths.cmdPath || "");
|
|
60
|
+
const rspPath = JSON.stringify(paths.rspPath || "");
|
|
61
|
+
return `ObjC.import("EventKit");
|
|
62
|
+
ObjC.import("Foundation");
|
|
63
|
+
${helpers}
|
|
64
|
+
|
|
65
|
+
var store = $.EKEventStore.alloc.init;
|
|
66
|
+
var cache = {};
|
|
67
|
+
|
|
68
|
+
function remember(ev) {
|
|
69
|
+
if (!ev) return;
|
|
70
|
+
var keys = [
|
|
71
|
+
jsString(ev.eventIdentifier),
|
|
72
|
+
jsString(ev.calendarItemIdentifier),
|
|
73
|
+
jsString(ev.calendarItemExternalIdentifier)
|
|
74
|
+
];
|
|
75
|
+
for (var i = 0; i < keys.length; i++) {
|
|
76
|
+
if (keys[i]) cache[keys[i]] = ev;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function forget(ev) {
|
|
81
|
+
var keys = [];
|
|
82
|
+
for (var k in cache) keys.push(k);
|
|
83
|
+
for (var i = 0; i < keys.length; i++) {
|
|
84
|
+
if (cache[keys[i]] === ev) delete cache[keys[i]];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resolve(ids) {
|
|
89
|
+
if (!ids) return null;
|
|
90
|
+
for (var i = 0; i < ids.length; i++) {
|
|
91
|
+
var id = ids[i];
|
|
92
|
+
if (id && cache[id]) return cache[id];
|
|
93
|
+
}
|
|
94
|
+
return findEventByIds(store, ids);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
var cmdPath = ${cmdPath};
|
|
98
|
+
var rspPath = ${rspPath};
|
|
99
|
+
|
|
100
|
+
function writeReply(s) {
|
|
101
|
+
$.NSString.stringWithString(String(s) + "\\n").writeToFileAtomicallyEncodingError(rspPath, true, $.NSUTF8StringEncoding, null);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function fileExists(p) {
|
|
105
|
+
try { return !!$.NSFileManager.defaultManager.fileExistsAtPath(p); } catch (e) { return false; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function readCmd() {
|
|
109
|
+
var str = $.NSString.stringWithContentsOfFileEncodingError(cmdPath, $.NSUTF8StringEncoding, null);
|
|
110
|
+
return jsString(str);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function removeCmd() {
|
|
114
|
+
try { $.NSFileManager.defaultManager.removeItemAtPathError(cmdPath, null); } catch (e) {}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function pickCalendar(calendarName) {
|
|
118
|
+
var defaultCal = null;
|
|
119
|
+
try { defaultCal = store.defaultCalendarForNewEvents; } catch (e) {}
|
|
120
|
+
var cals = null;
|
|
121
|
+
var count = 0;
|
|
122
|
+
try {
|
|
123
|
+
cals = store.calendarsForEntityType($.EKEntityTypeEvent);
|
|
124
|
+
count = cals ? Number(cals.count) : 0;
|
|
125
|
+
} catch (e) {}
|
|
126
|
+
var target = null;
|
|
127
|
+
var writables = [];
|
|
128
|
+
var wanted = String(calendarName || "").toLowerCase();
|
|
129
|
+
function matchesWanted(c) {
|
|
130
|
+
var t = titleOf(c).toLowerCase();
|
|
131
|
+
var id = identifierOf(c);
|
|
132
|
+
return (t && t === wanted) || (id && (id === calendarName || id.toLowerCase() === wanted));
|
|
133
|
+
}
|
|
134
|
+
for (var i = 0; i < count; i++) {
|
|
135
|
+
var c = cals.objectAtIndex(i);
|
|
136
|
+
var writable = true;
|
|
137
|
+
try { writable = !!c.allowsContentModifications; } catch (e) {}
|
|
138
|
+
if (!writable) continue;
|
|
139
|
+
writables.push(c);
|
|
140
|
+
if (matchesWanted(c)) {
|
|
141
|
+
target = c;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (!target && defaultCal && matchesWanted(defaultCal)) target = defaultCal;
|
|
146
|
+
if (!target && writables.length === 1) target = writables[0];
|
|
147
|
+
if (!target && defaultCal) target = defaultCal;
|
|
148
|
+
var status = $.EKEventStore.authorizationStatusForEntityType($.EKEntityTypeEvent);
|
|
149
|
+
if (!target) {
|
|
150
|
+
throw new Error("CALENDAR_NOT_FOUND status=" + status + " eventKitCalendars=" + count + " default=" + titleOf(defaultCal) + " defaultId=" + identifierOf(defaultCal));
|
|
151
|
+
}
|
|
152
|
+
return target;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function createEvent(cmd) {
|
|
156
|
+
var status = $.EKEventStore.authorizationStatusForEntityType($.EKEntityTypeEvent);
|
|
157
|
+
if (status === 1 || status === 2) throw new Error("EVENTKIT_DENIED status=" + status);
|
|
158
|
+
var target = pickCalendar(cmd.calendarName);
|
|
159
|
+
var event = null;
|
|
160
|
+
try { event = $.EKEvent.eventWithEventStore(store); } catch (e) {}
|
|
161
|
+
if (!event) {
|
|
162
|
+
try { event = $.EKEvent.alloc.initWithEventStore(store); } catch (e2) {}
|
|
163
|
+
}
|
|
164
|
+
if (!event) throw new Error("EVENTKIT_NO_EVENT status=" + status);
|
|
165
|
+
event.title = String(cmd.title || "");
|
|
166
|
+
event.startDate = $.NSDate.dateWithTimeIntervalSince1970(Number(cmd.startSec));
|
|
167
|
+
event.endDate = $.NSDate.dateWithTimeIntervalSince1970(Number(cmd.endSec));
|
|
168
|
+
event.allDay = !!cmd.allDay;
|
|
169
|
+
try { event.calendar = target; } catch (e) {}
|
|
170
|
+
try { if (event.setCalendar) event.setCalendar(target); } catch (e) {}
|
|
171
|
+
if (cmd.location) event.location = String(cmd.location);
|
|
172
|
+
if (cmd.notes) event.notes = String(cmd.notes);
|
|
173
|
+
var alerts = cmd.alerts || [];
|
|
174
|
+
if (alerts && alerts.length) {
|
|
175
|
+
var alarms = $.NSMutableArray.array;
|
|
176
|
+
for (var a = 0; a < alerts.length; a++) {
|
|
177
|
+
alarms.addObject($.EKAlarm.alarmWithRelativeOffset(-Number(alerts[a]) * 60));
|
|
178
|
+
}
|
|
179
|
+
event.alarms = alarms;
|
|
180
|
+
}
|
|
181
|
+
var err = Ref();
|
|
182
|
+
var ok = false;
|
|
183
|
+
try {
|
|
184
|
+
ok = store.saveEventSpanCommitError(event, $.EKSpanThisEvent, true, err);
|
|
185
|
+
} catch (e) {
|
|
186
|
+
throw new Error("EVENTKIT_SAVE_FAILED: " + String(e));
|
|
187
|
+
}
|
|
188
|
+
if (!ok) {
|
|
189
|
+
var desc = "unknown";
|
|
190
|
+
try { desc = String(err[0]); } catch (e) {}
|
|
191
|
+
throw new Error("EVENTKIT_SAVE_FAILED: " + desc + " status=" + status);
|
|
192
|
+
}
|
|
193
|
+
remember(event);
|
|
194
|
+
var externalId = "";
|
|
195
|
+
var localId = "";
|
|
196
|
+
var itemId = "";
|
|
197
|
+
try { externalId = jsString(event.calendarItemExternalIdentifier); } catch (e) {}
|
|
198
|
+
try { localId = jsString(event.eventIdentifier); } catch (e) {}
|
|
199
|
+
try { itemId = jsString(event.calendarItemIdentifier); } catch (e) {}
|
|
200
|
+
if (!externalId && !localId && !itemId) throw new Error("EVENTKIT_NO_ID status=" + status);
|
|
201
|
+
return externalId + "<<>>" + localId + "<<>>" + titleOf(target) + "<<>>" + itemId;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function updateEvent(cmd) {
|
|
205
|
+
var event = resolve(cmd.ids);
|
|
206
|
+
if (!event) {
|
|
207
|
+
var status = $.EKEventStore.authorizationStatusForEntityType($.EKEntityTypeEvent);
|
|
208
|
+
throw new Error("EVENTKIT_NOT_FOUND status=" + status + (status === 4 ? " writeOnly" : "") + " sessionMiss tried=" + (cmd.ids || []).join(","));
|
|
209
|
+
}
|
|
210
|
+
if (cmd.title !== undefined && cmd.title !== null) event.title = String(cmd.title);
|
|
211
|
+
if (cmd.location !== undefined && cmd.location !== null) event.location = String(cmd.location);
|
|
212
|
+
if (cmd.notes !== undefined && cmd.notes !== null) event.notes = String(cmd.notes);
|
|
213
|
+
if (cmd.startSec !== undefined && cmd.startSec !== null) {
|
|
214
|
+
event.startDate = $.NSDate.dateWithTimeIntervalSince1970(Number(cmd.startSec));
|
|
215
|
+
}
|
|
216
|
+
if (cmd.endSec !== undefined && cmd.endSec !== null) {
|
|
217
|
+
event.endDate = $.NSDate.dateWithTimeIntervalSince1970(Number(cmd.endSec));
|
|
218
|
+
}
|
|
219
|
+
if (cmd.clearAlerts) {
|
|
220
|
+
try { event.alarms = $.NSMutableArray.array; } catch (e) {}
|
|
221
|
+
}
|
|
222
|
+
var alerts = cmd.alerts || [];
|
|
223
|
+
if (alerts && alerts.length) {
|
|
224
|
+
var alarms = $.NSMutableArray.array;
|
|
225
|
+
for (var a = 0; a < alerts.length; a++) {
|
|
226
|
+
alarms.addObject($.EKAlarm.alarmWithRelativeOffset(-Number(alerts[a]) * 60));
|
|
227
|
+
}
|
|
228
|
+
event.alarms = alarms;
|
|
229
|
+
}
|
|
230
|
+
var err = Ref();
|
|
231
|
+
var ok = store.saveEventSpanCommitError(event, $.EKSpanThisEvent, true, err);
|
|
232
|
+
if (!ok) {
|
|
233
|
+
var desc = "unknown";
|
|
234
|
+
try { desc = String(err[0]); } catch (e) {}
|
|
235
|
+
throw new Error("EVENTKIT_SAVE_FAILED: " + desc);
|
|
236
|
+
}
|
|
237
|
+
remember(event);
|
|
238
|
+
return jsString(event.eventIdentifier) || jsString(event.calendarItemExternalIdentifier) || "1";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function removeEvent(cmd) {
|
|
242
|
+
var event = resolve(cmd.ids);
|
|
243
|
+
if (!event) {
|
|
244
|
+
var status = $.EKEventStore.authorizationStatusForEntityType($.EKEntityTypeEvent);
|
|
245
|
+
throw new Error("EVENTKIT_NOT_FOUND status=" + status + (status === 4 ? " writeOnly" : "") + " sessionMiss tried=" + (cmd.ids || []).join(","));
|
|
246
|
+
}
|
|
247
|
+
var err = Ref();
|
|
248
|
+
var ok = store.removeEventSpanCommitError(event, $.EKSpanThisEvent, true, err);
|
|
249
|
+
if (!ok) {
|
|
250
|
+
var desc = "unknown";
|
|
251
|
+
try { desc = String(err[0]); } catch (e) {}
|
|
252
|
+
throw new Error("EVENTKIT_REMOVE_FAILED: " + desc);
|
|
253
|
+
}
|
|
254
|
+
forget(event);
|
|
255
|
+
return "1";
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function handle(cmd) {
|
|
259
|
+
if (!cmd || !cmd.op) throw new Error("EVENTKIT_BAD_CMD");
|
|
260
|
+
if (cmd.op === "ping") return "pong";
|
|
261
|
+
if (cmd.op === "create") return createEvent(cmd);
|
|
262
|
+
if (cmd.op === "update") return updateEvent(cmd);
|
|
263
|
+
if (cmd.op === "remove") return removeEvent(cmd);
|
|
264
|
+
if (cmd.op === "quit") return "bye";
|
|
265
|
+
throw new Error("EVENTKIT_BAD_OP");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
while (true) {
|
|
269
|
+
if (!fileExists(cmdPath)) {
|
|
270
|
+
delay(0.05);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
var line = readCmd();
|
|
274
|
+
removeCmd();
|
|
275
|
+
line = String(line || "").replace(/^\\s+|\\s+$/g, "");
|
|
276
|
+
if (!line) continue;
|
|
277
|
+
try {
|
|
278
|
+
var cmd = JSON.parse(line);
|
|
279
|
+
var output = handle(cmd);
|
|
280
|
+
writeReply(JSON.stringify({ ok: true, output: String(output == null ? "" : output) }));
|
|
281
|
+
if (cmd.op === "quit") break;
|
|
282
|
+
} catch (e) {
|
|
283
|
+
writeReply(JSON.stringify({ ok: false, error: String(e) }));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function workerScriptPath(home = process.env.HOME || "") {
|
|
290
|
+
return sessionPaths(home).scriptPath;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function startEventKitSession({
|
|
294
|
+
helpers,
|
|
295
|
+
spawnImpl = spawn,
|
|
296
|
+
home = process.env.HOME || "",
|
|
297
|
+
timeoutMs = 30000,
|
|
298
|
+
sleep = (ms) => {
|
|
299
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
300
|
+
}
|
|
301
|
+
} = {}) {
|
|
302
|
+
if (!helpers) throw new Error("EventKit worker helpers are required");
|
|
303
|
+
const paths = sessionPaths(home);
|
|
304
|
+
fs.mkdirSync(paths.dir, { recursive: true, mode: 0o700 });
|
|
305
|
+
try { fs.unlinkSync(paths.cmdPath); } catch { /* ignore */ }
|
|
306
|
+
try { fs.unlinkSync(paths.rspPath); } catch { /* ignore */ }
|
|
307
|
+
fs.writeFileSync(paths.scriptPath, buildEventKitWorkerScript(helpers, paths), { mode: 0o600 });
|
|
308
|
+
|
|
309
|
+
const child = spawnImpl("osascript", ["-l", "JavaScript", paths.scriptPath], {
|
|
310
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
311
|
+
shell: false
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
let dead = false;
|
|
315
|
+
if (child.on) {
|
|
316
|
+
child.on("exit", () => {
|
|
317
|
+
dead = true;
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function request(cmd, waitMs = timeoutMs) {
|
|
322
|
+
if (dead) throw new Error("EVENTKIT_SESSION_DEAD");
|
|
323
|
+
try { fs.unlinkSync(paths.rspPath); } catch { /* ignore */ }
|
|
324
|
+
fs.writeFileSync(paths.cmdPath, JSON.stringify(cmd), { mode: 0o600 });
|
|
325
|
+
const deadline = Date.now() + waitMs;
|
|
326
|
+
while (Date.now() < deadline) {
|
|
327
|
+
if (fs.existsSync(paths.rspPath)) {
|
|
328
|
+
const text = fs.readFileSync(paths.rspPath, "utf8").trim();
|
|
329
|
+
try { fs.unlinkSync(paths.rspPath); } catch { /* ignore */ }
|
|
330
|
+
return JSON.parse(text);
|
|
331
|
+
}
|
|
332
|
+
sleep(20);
|
|
333
|
+
}
|
|
334
|
+
throw new Error("EVENTKIT_SESSION_TIMEOUT");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const pong = request({ op: "ping" });
|
|
338
|
+
if (!pong || pong.ok !== true || pong.output !== "pong") {
|
|
339
|
+
try { child.kill(); } catch { /* ignore */ }
|
|
340
|
+
throw new Error("EVENTKIT_SESSION_HANDSHAKE");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
request,
|
|
345
|
+
close() {
|
|
346
|
+
try { request({ op: "quit" }, 2000); } catch { /* ignore */ }
|
|
347
|
+
try { child.kill(); } catch { /* ignore */ }
|
|
348
|
+
dead = true;
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function ensureEventKitSession(opts = {}) {
|
|
354
|
+
if (singleton) return singleton;
|
|
355
|
+
if (!shouldStartEventKitSession(opts)) return null;
|
|
356
|
+
try {
|
|
357
|
+
singleton = startEventKitSession(opts);
|
|
358
|
+
return singleton;
|
|
359
|
+
} catch {
|
|
360
|
+
singleton = null;
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function closeEventKitSession() {
|
|
366
|
+
if (!singleton) return;
|
|
367
|
+
try { singleton.close(); } catch { /* ignore */ }
|
|
368
|
+
singleton = null;
|
|
369
|
+
}
|
package/lib/mailWrite.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import fs from "fs";
|
|
12
12
|
import path from "path";
|
|
13
13
|
import { validateEmailPath, unfoldRfc822Headers, safeMatch, stripHtmlTags } from "./validators.js";
|
|
14
|
-
import { runAppleScript, asString,
|
|
14
|
+
import { runAppleScript, asString, MAIL_TCC_GUIDANCE, ATTRIBUTION_GUIDANCE } from "./appleScript.js";
|
|
15
15
|
import {
|
|
16
16
|
planWrite,
|
|
17
17
|
validateEmailList,
|
|
@@ -98,6 +98,42 @@ function recipientLines(addresses, kind) {
|
|
|
98
98
|
.join("\n");
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Ship-gate / first-run probe: the compose verb that hangs when node → Mail
|
|
103
|
+
* Automation is denied. `tell Mail to get name` is not enough — Mini
|
|
104
|
+
* diagnosis showed that returns while `make new outgoing message` blocks.
|
|
105
|
+
* Nothing is sent; the outgoing message is deleted immediately.
|
|
106
|
+
*/
|
|
107
|
+
export const MAIL_AUTOMATION_PROBE_SUBJECT = "ATM Mail Automation probe";
|
|
108
|
+
|
|
109
|
+
export function buildMailAutomationProbeScript() {
|
|
110
|
+
return `tell application "Mail"
|
|
111
|
+
set probe to make new outgoing message with properties {subject:${asString(MAIL_AUTOMATION_PROBE_SUBJECT)}, content:"", visible:false}
|
|
112
|
+
delete probe
|
|
113
|
+
end tell
|
|
114
|
+
return "OK"`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Live Mail Apple Events check. Not a dry_run: that path never talks to Mail.
|
|
119
|
+
* @returns {{ ok: boolean, message: string }}
|
|
120
|
+
*/
|
|
121
|
+
export function probeMailAutomation() {
|
|
122
|
+
const action = "mail_automation_probe";
|
|
123
|
+
const summary = "compose a temporary outgoing message in Mail (Automation / Apple Events check; nothing is sent)";
|
|
124
|
+
const result = runAppleScript(buildMailAutomationProbeScript(), { timeout: 30000, appName: "Mail" });
|
|
125
|
+
if (!result.ok) {
|
|
126
|
+
return { ok: false, message: failure(action, summary, result, []) };
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
ok: true,
|
|
130
|
+
message: writeSuccessMessage(
|
|
131
|
+
action,
|
|
132
|
+
"Mail Automation allowed (composed and discarded a temporary outgoing message; nothing was sent)"
|
|
133
|
+
)
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
101
137
|
/**
|
|
102
138
|
* Build the outgoing-message script shared by send and draft.
|
|
103
139
|
*
|
|
@@ -136,8 +172,8 @@ function summarizeRecipients(to, cc, bcc) {
|
|
|
136
172
|
}
|
|
137
173
|
|
|
138
174
|
function failure(action, summary, result, secrets) {
|
|
139
|
-
if (result.kind === "tcc") {
|
|
140
|
-
return `${action} failed — attempted to ${summary}. ${
|
|
175
|
+
if (result.kind === "tcc" || result.kind === "timeout") {
|
|
176
|
+
return `${action} failed — attempted to ${summary}. ${MAIL_TCC_GUIDANCE}`;
|
|
141
177
|
}
|
|
142
178
|
if (result.kind === "not_found") {
|
|
143
179
|
return `${action} failed — attempted to ${summary}. The message could not be found in Mail. Pass a message_id from a current mail_search result.`;
|
package/lib/messagesWrite.js
CHANGED
|
@@ -14,7 +14,7 @@ import fs from "fs";
|
|
|
14
14
|
import path from "path";
|
|
15
15
|
import { safeSqlite3Json } from "./shell.js";
|
|
16
16
|
import { escapeSQL } from "./validators.js";
|
|
17
|
-
import { runAppleScript, asString,
|
|
17
|
+
import { runAppleScript, asString, MESSAGES_TCC_GUIDANCE, ATTRIBUTION_GUIDANCE } from "./appleScript.js";
|
|
18
18
|
import {
|
|
19
19
|
planWrite,
|
|
20
20
|
normalizeList,
|
|
@@ -95,6 +95,38 @@ export function validateAttachmentPath(value) {
|
|
|
95
95
|
return { filePath: resolved, error: null };
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Ship-gate / first-run probe: the Messages Apple Events used before send
|
|
100
|
+
* (enumerate accounts / service type). `messages_send` dry_run never talks
|
|
101
|
+
* to Messages. Nothing is sent.
|
|
102
|
+
*/
|
|
103
|
+
export function buildMessagesAutomationProbeScript() {
|
|
104
|
+
return `tell application "Messages"
|
|
105
|
+
repeat with acc in accounts
|
|
106
|
+
try
|
|
107
|
+
get service type of acc
|
|
108
|
+
end try
|
|
109
|
+
end repeat
|
|
110
|
+
end tell
|
|
111
|
+
return "OK"`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function probeMessagesAutomation() {
|
|
115
|
+
const action = "messages_automation_probe";
|
|
116
|
+
const summary = "enumerate Messages accounts (Automation / Apple Events check; nothing is sent)";
|
|
117
|
+
const result = runAppleScript(buildMessagesAutomationProbeScript(), { timeout: 30000, appName: "Messages" });
|
|
118
|
+
if (!result.ok) {
|
|
119
|
+
return { ok: false, message: failure(action, summary, result, []) };
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
ok: true,
|
|
123
|
+
message: writeSuccessMessage(
|
|
124
|
+
action,
|
|
125
|
+
"Messages Automation allowed (enumerated accounts; nothing was sent)"
|
|
126
|
+
)
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
98
130
|
function serviceHandler() {
|
|
99
131
|
return `on atmService(kind)
|
|
100
132
|
tell application "Messages"
|
|
@@ -159,8 +191,8 @@ return "OK"`;
|
|
|
159
191
|
}
|
|
160
192
|
|
|
161
193
|
function failure(action, summary, result, secrets) {
|
|
162
|
-
if (result.kind === "tcc") {
|
|
163
|
-
return `${action} failed — attempted to ${summary}. ${
|
|
194
|
+
if (result.kind === "tcc" || result.kind === "timeout") {
|
|
195
|
+
return `${action} failed — attempted to ${summary}. ${MESSAGES_TCC_GUIDANCE}`;
|
|
164
196
|
}
|
|
165
197
|
if (result.kind === "attribution") {
|
|
166
198
|
return `${action} failed — attempted to ${summary}. ${ATTRIBUTION_GUIDANCE}`;
|
package/lib/shell.js
CHANGED
|
@@ -109,31 +109,49 @@ export function safeSqlite3Json(dbPath, query, options = {}) {
|
|
|
109
109
|
* @returns {string} Output from AppleScript
|
|
110
110
|
* @throws {Error} If execution fails
|
|
111
111
|
*/
|
|
112
|
+
const OSASCRIPT_LANGUAGES = new Set(["JavaScript"]);
|
|
113
|
+
|
|
112
114
|
export function safeOsascript(script, options = {}) {
|
|
113
115
|
const {
|
|
114
|
-
timeout = 30000
|
|
116
|
+
timeout = 30000,
|
|
117
|
+
language = null
|
|
115
118
|
} = options;
|
|
116
119
|
|
|
117
120
|
if (!script || typeof script !== 'string') {
|
|
118
121
|
throw new Error('AppleScript is required');
|
|
119
122
|
}
|
|
120
123
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
+
const args = [];
|
|
125
|
+
if (language) {
|
|
126
|
+
if (!OSASCRIPT_LANGUAGES.has(language)) {
|
|
127
|
+
throw new Error(`Unsupported osascript language: ${language}`);
|
|
128
|
+
}
|
|
129
|
+
args.push("-l", language);
|
|
130
|
+
}
|
|
131
|
+
args.push("-e", script);
|
|
132
|
+
|
|
133
|
+
// Use -e with the script as an argument (never a shell string).
|
|
134
|
+
const result = spawnSync('osascript', args, {
|
|
124
135
|
encoding: 'utf-8',
|
|
125
136
|
timeout,
|
|
126
137
|
shell: false
|
|
127
138
|
});
|
|
128
139
|
|
|
140
|
+
const stderr = String(result.stderr || "").trim().slice(0, 500);
|
|
141
|
+
|
|
129
142
|
if (result.error) {
|
|
143
|
+
if (stderr) {
|
|
144
|
+
const wrapped = new Error(`${result.error.message}; osascript stderr: ${stderr}`);
|
|
145
|
+
wrapped.code = result.error.code;
|
|
146
|
+
throw wrapped;
|
|
147
|
+
}
|
|
130
148
|
throw result.error;
|
|
131
149
|
}
|
|
132
150
|
|
|
133
151
|
// osascript may return non-zero for certain operations
|
|
134
152
|
// Return stdout if we have it, otherwise throw
|
|
135
153
|
if (result.status !== 0 && !result.stdout) {
|
|
136
|
-
const errorMsg =
|
|
154
|
+
const errorMsg = stderr || `osascript exited with code ${result.status}`;
|
|
137
155
|
throw new Error(errorMsg);
|
|
138
156
|
}
|
|
139
157
|
|
package/lib/writeGuards.js
CHANGED
|
@@ -44,6 +44,7 @@ const EMAIL_RE = /^[A-Za-z0-9._%+'-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{
|
|
|
44
44
|
const PHONE_RE = /^[+(]?[0-9(][0-9 ().-]{4,24}$/;
|
|
45
45
|
const MESSAGE_ID_RE = /^<?[A-Za-z0-9!#$%&'*+/=?^_{|}~.@-]{1,500}>?$/;
|
|
46
46
|
const EVENT_UID_RE = /^[A-Za-z0-9._:@+-]{1,255}$/;
|
|
47
|
+
const EVENTKIT_ID_RE = /^[A-Za-z0-9._:@+/=-]{1,500}$/;
|
|
47
48
|
const CONTACT_ID_RE = /^[A-Za-z0-9._:-]{1,255}$/;
|
|
48
49
|
const CHAT_GUID_RE = /^[A-Za-z0-9;:+._@-]{1,255}$/;
|
|
49
50
|
const LABEL_RE = /^[A-Za-z][A-Za-z ]{0,19}$/;
|
|
@@ -80,6 +81,13 @@ export function validateEventId(value) {
|
|
|
80
81
|
return EVENT_UID_RE.test(trimmed) ? trimmed : null;
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
/** EventKit `eventIdentifier` (may include /RID= for recurrences). */
|
|
85
|
+
export function validateEventKitId(value) {
|
|
86
|
+
if (typeof value !== "string") return null;
|
|
87
|
+
const trimmed = value.trim();
|
|
88
|
+
return EVENTKIT_ID_RE.test(trimmed) ? trimmed : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
83
91
|
export function validateContactId(value) {
|
|
84
92
|
if (typeof value !== "string") return null;
|
|
85
93
|
const trimmed = value.trim();
|
package/lib/writeRouting.js
CHANGED
|
@@ -63,7 +63,7 @@ export function planAfterDelegation({ delivered, response }) {
|
|
|
63
63
|
*/
|
|
64
64
|
export function tccFallbackAdvice({ bridgeAvailable }) {
|
|
65
65
|
if (bridgeAvailable) {
|
|
66
|
-
return "The indexer daemon was reachable but the write was still denied; grant the daemon's node binary Full Disk Access (reads) and Allow node in System Settings > Privacy & Security > Automation for Contacts.app and Calendar.app. Do not add node via + in the Contacts or Calendars privacy lists — those panes often have no Add button.";
|
|
66
|
+
return "The indexer daemon was reachable but the write was still denied; grant the daemon's node binary Full Disk Access (reads) and Allow node in System Settings > Privacy & Security > Automation for Mail.app, Messages.app, Contacts.app, and Calendar.app. A hang or timeout on Mail compose is TCC / Automation denied, not Mail.app missing. Do not add node via + in the Contacts or Calendars privacy lists — those panes often have no Add button.";
|
|
67
67
|
}
|
|
68
|
-
return "No indexer daemon is running on this Mac. Start apple-tools-indexer (the LaunchAgent) so writes execute under node, which macOS can grant Contacts
|
|
68
|
+
return "No indexer daemon is running on this Mac. Start apple-tools-indexer (the LaunchAgent) so writes execute under node, which macOS can grant Mail, Messages, Contacts, and Calendar Automation to directly.";
|
|
69
69
|
}
|
package/lib/writeTools.js
CHANGED
|
@@ -207,6 +207,7 @@ export const WRITE_TOOL_DEFINITIONS = [
|
|
|
207
207
|
type: "object",
|
|
208
208
|
properties: {
|
|
209
209
|
event_id: { type: "string", description: "Event ID from calendar_date or calendar_add (required)" },
|
|
210
|
+
eventkit_id: { type: "string", description: "EventKit eventIdentifier from calendar_add (via EventKit). Prefer this over Calendar.app uid lookup." },
|
|
210
211
|
title: { type: "string", description: "New title" },
|
|
211
212
|
start: { type: "string", description: "New start as YYYY-MM-DD HH:MM local time" },
|
|
212
213
|
end: { type: "string", description: "New end as YYYY-MM-DD HH:MM local time" },
|
|
@@ -231,7 +232,9 @@ export const WRITE_TOOL_DEFINITIONS = [
|
|
|
231
232
|
inputSchema: {
|
|
232
233
|
type: "object",
|
|
233
234
|
properties: {
|
|
234
|
-
event_id: { type: "string", description: "Event ID from calendar_date (required)" },
|
|
235
|
+
event_id: { type: "string", description: "Event ID from calendar_date or calendar_add (required)" },
|
|
236
|
+
eventkit_id: { type: "string", description: "Optional EventKit eventIdentifier returned by calendar_add (via EventKit). Needed so writeOnly EventKit can delete the event it created." },
|
|
237
|
+
calendar_name: { type: "string", description: "Optional calendar from calendar_list_calendars; narrows the AppleScript delete so Calendar does not scan every calendar" },
|
|
235
238
|
...CONFIRM_PROPS
|
|
236
239
|
},
|
|
237
240
|
required: ["event_id"]
|
|
@@ -327,6 +330,13 @@ export const WRITE_TOOL_HANDLERS = {
|
|
|
327
330
|
contacts_remove: contactsRemove
|
|
328
331
|
};
|
|
329
332
|
|
|
333
|
+
/**
|
|
334
|
+
* Smoke-only helpers, never MCP write tools. CallTool uses isWriteTool, so
|
|
335
|
+
* these names must stay off WRITE_TOOL_HANDLERS or a client can invoke them
|
|
336
|
+
* without dry_run / confirm.
|
|
337
|
+
*/
|
|
338
|
+
export const SMOKE_ONLY_AUTOMATION_PROBES = ["mail_automation_probe", "messages_automation_probe"];
|
|
339
|
+
|
|
330
340
|
export const WRITE_TOOL_NAMES = Object.keys(WRITE_TOOL_HANDLERS);
|
|
331
341
|
|
|
332
342
|
export function isWriteTool(name) {
|
|
@@ -368,7 +378,7 @@ export async function dispatchWriteTool(name, args = {}, deps = {}) {
|
|
|
368
378
|
log = () => {}
|
|
369
379
|
} = deps;
|
|
370
380
|
|
|
371
|
-
if (!isWriteTool(name)) {
|
|
381
|
+
if (SMOKE_ONLY_AUTOMATION_PROBES.includes(name) || !isWriteTool(name)) {
|
|
372
382
|
return { ok: false, message: `Unknown write tool: ${name}` };
|
|
373
383
|
}
|
|
374
384
|
|
|
@@ -389,7 +399,12 @@ export async function dispatchWriteTool(name, args = {}, deps = {}) {
|
|
|
389
399
|
}
|
|
390
400
|
|
|
391
401
|
const result = runLocally(name, args);
|
|
392
|
-
if (
|
|
402
|
+
if (
|
|
403
|
+
result &&
|
|
404
|
+
result.ok === false &&
|
|
405
|
+
result.suppressTccAdvice !== true &&
|
|
406
|
+
isTccDenial(result.message)
|
|
407
|
+
) {
|
|
393
408
|
return { ...result, message: `${result.message} ${tccFallbackAdvice({ bridgeAvailable })}` };
|
|
394
409
|
}
|
|
395
410
|
return result;
|