apple-tools-mcp 2.0.0 → 2.0.2
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 +78 -20
- package/index.js +30 -7
- package/indexer.js +4 -1
- package/lib/appleScript.js +209 -21
- package/lib/calendarWrite.js +835 -23
- package/lib/contactsWrite.js +192 -5
- package/lib/eventKitSession.js +369 -0
- package/lib/mailWrite.js +332 -26
- package/lib/messagesWrite.js +39 -3
- package/lib/permissions.js +360 -0
- package/lib/processMode.js +47 -0
- package/lib/shell.js +50 -5
- package/lib/writeGuards.js +8 -0
- package/lib/writeRouting.js +76 -6
- package/lib/writeTools.js +26 -8
- package/package.json +4 -1
- package/scripts/postinstall.js +32 -0
- package/scripts/smoke-writes.js +188 -17
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-run / upgrade permissions command.
|
|
3
|
+
*
|
|
4
|
+
* Probes Contacts, Calendar, Mail, and Messages under process.execPath so
|
|
5
|
+
* macOS can pop Allow dialogs for that node binary. The user clicks Allow;
|
|
6
|
+
* this command cannot grant silently.
|
|
7
|
+
*
|
|
8
|
+
* Always runs in this process (never via the write bridge): the point is to
|
|
9
|
+
* attach the dialogs to process.execPath. If the invoked CLI / shebang
|
|
10
|
+
* points at a different node, print a WARN — Allows attach to execPath.
|
|
11
|
+
* Mail and Messages use the existing live Apple Events helpers — dry_run of
|
|
12
|
+
* mail_send / messages_send never talks to those apps and does not count.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from "fs";
|
|
16
|
+
import path from "path";
|
|
17
|
+
import { classifyAppleScriptError } from "./appleScript.js";
|
|
18
|
+
import { probeMailAutomation } from "./mailWrite.js";
|
|
19
|
+
import { probeMessagesAutomation } from "./messagesWrite.js";
|
|
20
|
+
import { probeContactsAutomation } from "./contactsWrite.js";
|
|
21
|
+
import { probeCalendarAutomation } from "./calendarWrite.js";
|
|
22
|
+
import { detectLaunchAgentContext, hostAutomationAdvice } from "./writeRouting.js";
|
|
23
|
+
|
|
24
|
+
export const REQUIRED_SURFACES = ["Contacts", "Calendar", "Mail", "Messages"];
|
|
25
|
+
|
|
26
|
+
/** Mini ship-gate host example — not a universal path. */
|
|
27
|
+
export const EXAMPLE_MINI_NODE = "/Users/petercoates/.local/node/bin/node";
|
|
28
|
+
|
|
29
|
+
/** MacBook Claude nvm example — not Homebrew. */
|
|
30
|
+
export const EXAMPLE_MACBOOK_NVM_NODE = "/Users/petercoates/.nvm/versions/node/v22.21.1/bin/node";
|
|
31
|
+
|
|
32
|
+
export const GRANT_GRANTED = "granted";
|
|
33
|
+
export const GRANT_MISSING = "missing";
|
|
34
|
+
export const GRANT_ERROR = "error";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} [execPath=process.execPath]
|
|
38
|
+
* @returns {{ execPath: string, miniExample: string, macbookExample: string }}
|
|
39
|
+
*/
|
|
40
|
+
export function describeProbeBinary(execPath = process.execPath) {
|
|
41
|
+
return {
|
|
42
|
+
execPath: String(execPath || ""),
|
|
43
|
+
miniExample: EXAMPLE_MINI_NODE,
|
|
44
|
+
macbookExample: EXAMPLE_MACBOOK_NVM_NODE
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve a path for comparison. Symlinks collapse to the real file when
|
|
50
|
+
* possible so `.../bin/node` and its target are treated as the same binary.
|
|
51
|
+
*/
|
|
52
|
+
export function normalizeNodePath(filePath, { realpathSync = fs.realpathSync } = {}) {
|
|
53
|
+
if (!filePath) return "";
|
|
54
|
+
const resolved = path.resolve(String(filePath));
|
|
55
|
+
try {
|
|
56
|
+
return realpathSync(resolved);
|
|
57
|
+
} catch {
|
|
58
|
+
return resolved;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* First-line `#!` interpreter of an invoked CLI script, if any.
|
|
64
|
+
* @returns {{ kind: "env"|"absolute", target: string, raw: string }|null}
|
|
65
|
+
*/
|
|
66
|
+
export function readShebangTarget(scriptPath, { readFileSync = fs.readFileSync } = {}) {
|
|
67
|
+
if (!scriptPath) return null;
|
|
68
|
+
try {
|
|
69
|
+
const line = String(readFileSync(scriptPath, "utf8")).split(/\r?\n/, 1)[0] || "";
|
|
70
|
+
if (!line.startsWith("#!")) return null;
|
|
71
|
+
const raw = line.slice(2).trim();
|
|
72
|
+
const parts = raw.split(/\s+/).filter(Boolean);
|
|
73
|
+
if (parts.length === 0) return null;
|
|
74
|
+
if (parts[0] === "/usr/bin/env" || parts[0].endsWith("/env")) {
|
|
75
|
+
return { kind: "env", target: parts[1] || "node", raw };
|
|
76
|
+
}
|
|
77
|
+
return { kind: "absolute", target: parts[0], raw };
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Detect when the invoked `apple-tools-mcp` path / shebang / argv[0] is a
|
|
85
|
+
* different node than process.execPath. Allows attach to execPath.
|
|
86
|
+
*/
|
|
87
|
+
export function detectExecPathMismatch({
|
|
88
|
+
execPath = process.execPath,
|
|
89
|
+
argv = process.argv,
|
|
90
|
+
realpathSync = fs.realpathSync,
|
|
91
|
+
existsSync = fs.existsSync,
|
|
92
|
+
readFileSync = fs.readFileSync
|
|
93
|
+
} = {}) {
|
|
94
|
+
const deps = { realpathSync, existsSync, readFileSync };
|
|
95
|
+
const execNorm = normalizeNodePath(execPath, deps);
|
|
96
|
+
const argv0 = argv && argv[0] ? String(argv[0]) : "";
|
|
97
|
+
const invokedCli = argv && argv[1] ? String(argv[1]) : "";
|
|
98
|
+
const argv0Norm = argv0 ? normalizeNodePath(argv0, deps) : "";
|
|
99
|
+
|
|
100
|
+
const reasons = [];
|
|
101
|
+
let siblingNode = "";
|
|
102
|
+
let shebangTarget = "";
|
|
103
|
+
|
|
104
|
+
if (argv0Norm && execNorm && argv0Norm !== execNorm) {
|
|
105
|
+
reasons.push("argv0");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (invokedCli) {
|
|
109
|
+
const sibling = path.join(path.dirname(path.resolve(invokedCli)), "node");
|
|
110
|
+
if (existsSync(sibling)) {
|
|
111
|
+
siblingNode = normalizeNodePath(sibling, deps);
|
|
112
|
+
if (siblingNode && execNorm && siblingNode !== execNorm) {
|
|
113
|
+
reasons.push("sibling");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const shebang = readShebangTarget(invokedCli, deps);
|
|
117
|
+
if (shebang && shebang.kind === "absolute" && shebang.target) {
|
|
118
|
+
shebangTarget = normalizeNodePath(shebang.target, deps);
|
|
119
|
+
if (shebangTarget && execNorm && shebangTarget !== execNorm) {
|
|
120
|
+
reasons.push("shebang");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
mismatch: reasons.length > 0,
|
|
127
|
+
reasons,
|
|
128
|
+
execPath: execNorm || String(execPath || ""),
|
|
129
|
+
argv0: argv0Norm || argv0,
|
|
130
|
+
invokedCli,
|
|
131
|
+
siblingNode,
|
|
132
|
+
shebangTarget
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Loud WARN: Allows attach to execPath, not the CLI path the user typed.
|
|
138
|
+
*/
|
|
139
|
+
export function formatExecPathMismatchWarn(info) {
|
|
140
|
+
if (!info || !info.mismatch) return [];
|
|
141
|
+
const cli = info.invokedCli || "apple-tools-mcp";
|
|
142
|
+
return [
|
|
143
|
+
"",
|
|
144
|
+
"WARN: Allow dialogs attach to process.execPath, not the apple-tools-mcp path you typed.",
|
|
145
|
+
` Invoked CLI: ${cli}`,
|
|
146
|
+
` process.argv[0]: ${info.argv0 || "(unknown)"}`,
|
|
147
|
+
info.siblingNode ? ` Node next to CLI: ${info.siblingNode}` : null,
|
|
148
|
+
info.shebangTarget ? ` CLI shebang target: ${info.shebangTarget}` : null,
|
|
149
|
+
` process.execPath: ${info.execPath} ← Allows attach HERE`,
|
|
150
|
+
"Re-run with that exact node so execPath matches the binary you intend:",
|
|
151
|
+
` ${info.execPath} ${cli} permissions`,
|
|
152
|
+
" or: $(which node) $(which apple-tools-mcp) permissions",
|
|
153
|
+
' or: node "$(dirname "$(which node)")/../lib/node_modules/apple-tools-mcp/index.js" permissions',
|
|
154
|
+
""
|
|
155
|
+
].filter((line) => line !== null);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Map a probe result to the grant report vocabulary.
|
|
160
|
+
* TCC / timeout / attribution on a live Apple Event is a missing Allow.
|
|
161
|
+
*
|
|
162
|
+
* @param {{ ok?: boolean, kind?: string|null, message?: string, error?: string }} result
|
|
163
|
+
* @returns {"granted"|"missing"|"error"}
|
|
164
|
+
*/
|
|
165
|
+
export function classifyGrantStatus(result) {
|
|
166
|
+
if (result && result.ok === true) return GRANT_GRANTED;
|
|
167
|
+
const text = String((result && (result.message || result.error)) || "");
|
|
168
|
+
const kind = (result && result.kind) || classifyAppleScriptError(text);
|
|
169
|
+
if (kind === "tcc" || kind === "timeout" || kind === "attribution") {
|
|
170
|
+
return GRANT_MISSING;
|
|
171
|
+
}
|
|
172
|
+
if (kind === "app_not_running") {
|
|
173
|
+
return GRANT_ERROR;
|
|
174
|
+
}
|
|
175
|
+
if (/tcc|automation deny|not authorized|not permitted|not allowed|timed out|etimedout|responsible-process|attribution/i.test(text)) {
|
|
176
|
+
return GRANT_MISSING;
|
|
177
|
+
}
|
|
178
|
+
return GRANT_ERROR;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* @param {Record<string, string>} grants
|
|
183
|
+
* @returns {string[]}
|
|
184
|
+
*/
|
|
185
|
+
export function formatGrantReport(grants) {
|
|
186
|
+
return REQUIRED_SURFACES.map((name) => `${name} = ${grants[name] || GRANT_ERROR}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Fail closed: any required surface that is not granted is a non-zero exit.
|
|
191
|
+
*
|
|
192
|
+
* @param {Record<string, string>} grants
|
|
193
|
+
* @returns {number}
|
|
194
|
+
*/
|
|
195
|
+
export function exitCodeForGrants(grants) {
|
|
196
|
+
return REQUIRED_SURFACES.every((name) => grants[name] === GRANT_GRANTED) ? 0 : 1;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Advisory Full Disk Access touch. Read tools need FDA on this node; it is
|
|
201
|
+
* not one of the four required Automation grants and never fails the command.
|
|
202
|
+
*
|
|
203
|
+
* @returns {{ status: "readable"|"missing"|"skipped", message: string }}
|
|
204
|
+
*/
|
|
205
|
+
export function probeFullDiskAccess({
|
|
206
|
+
home = process.env.HOME,
|
|
207
|
+
accessFn = fs.accessSync
|
|
208
|
+
} = {}) {
|
|
209
|
+
if (!home) {
|
|
210
|
+
return { status: "skipped", message: "HOME is unset; skipped Full Disk Access probe" };
|
|
211
|
+
}
|
|
212
|
+
const targets = [
|
|
213
|
+
path.join(home, "Library", "Mail"),
|
|
214
|
+
path.join(home, "Library", "Messages", "chat.db"),
|
|
215
|
+
path.join(home, "Library", "Application Support", "AddressBook")
|
|
216
|
+
];
|
|
217
|
+
let sawPath = false;
|
|
218
|
+
for (const target of targets) {
|
|
219
|
+
try {
|
|
220
|
+
accessFn(target, fs.constants.R_OK);
|
|
221
|
+
sawPath = true;
|
|
222
|
+
} catch (e) {
|
|
223
|
+
const code = e && e.code ? e.code : "";
|
|
224
|
+
if (code === "EPERM" || code === "EACCES") {
|
|
225
|
+
return {
|
|
226
|
+
status: "missing",
|
|
227
|
+
message: `Full Disk Access looks missing for this node (${code} reading ${path.basename(target)}). Reads need FDA on node; that is separate from Automation.`
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
if (code === "ENOENT") {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
status: "skipped",
|
|
235
|
+
message: `Full Disk Access probe skipped (${code || "error"} on ${path.basename(target)})`
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (!sawPath) {
|
|
240
|
+
return { status: "skipped", message: "Mail / Messages / AddressBook paths are not present; skipped Full Disk Access probe" };
|
|
241
|
+
}
|
|
242
|
+
return { status: "readable", message: "Mail / Messages / AddressBook paths are readable (Full Disk Access looks present)" };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function defaultPermissionsProbes() {
|
|
246
|
+
return {
|
|
247
|
+
Contacts: probeContactsAutomation,
|
|
248
|
+
Calendar: probeCalendarAutomation,
|
|
249
|
+
Mail: probeMailAutomation,
|
|
250
|
+
Messages: probeMessagesAutomation
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function bannerLines(binary, version) {
|
|
255
|
+
return [
|
|
256
|
+
`Apple Tools MCP permissions (v${version})`,
|
|
257
|
+
"=".repeat(60),
|
|
258
|
+
`Probing node binary: ${binary.execPath}`,
|
|
259
|
+
"This is process.execPath — the node that is running this command.",
|
|
260
|
+
`Mini example: ${binary.miniExample}`,
|
|
261
|
+
`MacBook example: ${binary.macbookExample} (Claude nvm; not Homebrew)`,
|
|
262
|
+
"Invoke this command with the product node so Allow dialogs attach to it.",
|
|
263
|
+
"",
|
|
264
|
+
"Open System Settings → Privacy & Security → Automation on this Mac.",
|
|
265
|
+
"When macOS asks, click Allow for THIS node — not the MCP host app.",
|
|
266
|
+
"Do not add node via + in the Contacts or Calendars privacy lists.",
|
|
267
|
+
"dry_run of mail_send / messages_send does not count: this command uses real Apple Events.",
|
|
268
|
+
"Nothing is sent to third parties. Mail composes and discards a temporary outgoing message.",
|
|
269
|
+
"Messages only enumerates accounts. Contacts creates and deletes a throwaway person in-script.",
|
|
270
|
+
"Calendar lists calendars only — no events are created."
|
|
271
|
+
];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Run the four live probes and print a grant report.
|
|
276
|
+
*
|
|
277
|
+
* @param {object} [options]
|
|
278
|
+
* @param {string} [options.execPath]
|
|
279
|
+
* @param {string[]} [options.argv]
|
|
280
|
+
* @param {string} [options.version]
|
|
281
|
+
* @param {(msg: string) => void} [options.stdout]
|
|
282
|
+
* @param {Record<string, () => object|Promise<object>>} [options.probes]
|
|
283
|
+
* @param {() => { status: string, message: string }} [options.fdaProbe]
|
|
284
|
+
* @returns {Promise<number>}
|
|
285
|
+
*/
|
|
286
|
+
export async function runPermissionsCommand({
|
|
287
|
+
execPath = process.execPath,
|
|
288
|
+
argv = process.argv,
|
|
289
|
+
version = "",
|
|
290
|
+
stdout = console.log,
|
|
291
|
+
probes,
|
|
292
|
+
fdaProbe = probeFullDiskAccess,
|
|
293
|
+
realpathSync = fs.realpathSync,
|
|
294
|
+
existsSync = fs.existsSync,
|
|
295
|
+
readFileSync = fs.readFileSync
|
|
296
|
+
} = {}) {
|
|
297
|
+
const binary = describeProbeBinary(execPath);
|
|
298
|
+
const mismatch = detectExecPathMismatch({
|
|
299
|
+
execPath,
|
|
300
|
+
argv,
|
|
301
|
+
realpathSync,
|
|
302
|
+
existsSync,
|
|
303
|
+
readFileSync
|
|
304
|
+
});
|
|
305
|
+
const launchAgent = detectLaunchAgentContext({ existsSync });
|
|
306
|
+
const hostAdvice = hostAutomationAdvice({ launchAgent, execPath });
|
|
307
|
+
const probeFns = { ...defaultPermissionsProbes(), ...(probes || {}) };
|
|
308
|
+
const log = (msg) => {
|
|
309
|
+
stdout(msg);
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
for (const line of bannerLines(binary, version)) {
|
|
313
|
+
log(line);
|
|
314
|
+
}
|
|
315
|
+
for (const line of formatExecPathMismatchWarn(mismatch)) {
|
|
316
|
+
log(line);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const grants = {};
|
|
320
|
+
for (const surface of REQUIRED_SURFACES) {
|
|
321
|
+
log("");
|
|
322
|
+
log(`--- ${surface} ---`);
|
|
323
|
+
log(`Next dialog: click Allow for node → ${surface} (if asked). Already-granted surfaces stay quiet.`);
|
|
324
|
+
let result;
|
|
325
|
+
try {
|
|
326
|
+
result = await Promise.resolve(probeFns[surface]());
|
|
327
|
+
} catch (e) {
|
|
328
|
+
result = { ok: false, message: e && e.message ? e.message : String(e), kind: "unknown" };
|
|
329
|
+
}
|
|
330
|
+
const status = classifyGrantStatus(result);
|
|
331
|
+
grants[surface] = status;
|
|
332
|
+
log(`[${status}] ${surface}: ${result && result.message ? result.message : ""}`);
|
|
333
|
+
if (status !== GRANT_GRANTED) {
|
|
334
|
+
log(` ${hostAdvice}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const fda = fdaProbe();
|
|
339
|
+
log("");
|
|
340
|
+
log(`--- Full Disk Access (advisory) ---`);
|
|
341
|
+
log(`[${fda.status}] ${fda.message}`);
|
|
342
|
+
|
|
343
|
+
log("");
|
|
344
|
+
log("=".repeat(60));
|
|
345
|
+
log("Grant report");
|
|
346
|
+
for (const line of formatGrantReport(grants)) {
|
|
347
|
+
log(` ${line}`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const code = exitCodeForGrants(grants);
|
|
351
|
+
if (code === 0) {
|
|
352
|
+
log("Result: PASS — Contacts, Calendar, Mail, and Messages are granted for this node.");
|
|
353
|
+
log("Safe to re-run; already-granted surfaces report OK without another click.");
|
|
354
|
+
} else {
|
|
355
|
+
log("Result: INCOMPLETE — one or more required grants are missing or errored.");
|
|
356
|
+
log(hostAdvice);
|
|
357
|
+
log("Click Allow for the missing surfaces and re-run this command. Exit is non-zero (fail closed).");
|
|
358
|
+
}
|
|
359
|
+
return code;
|
|
360
|
+
}
|
package/lib/processMode.js
CHANGED
|
@@ -3,11 +3,54 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Canonical indexer entrypoint: `node index.js --mode=indexer`
|
|
5
5
|
* Convenience bin: `apple-tools-indexer` (same file; detected via argv[1]).
|
|
6
|
+
* Permissions CLI: `apple-tools-mcp permissions` / `--mode=permissions`.
|
|
6
7
|
* MCP stdio remains the default when neither is present.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import path from "path";
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* First positional user argument, skipping flags (`--foo` / `--foo=bar`).
|
|
14
|
+
* Used so `node index.js permissions` and `apple-tools-mcp permissions`
|
|
15
|
+
* both resolve as the permissions CLI.
|
|
16
|
+
*
|
|
17
|
+
* @param {string[]} argv
|
|
18
|
+
* @returns {string|null}
|
|
19
|
+
*/
|
|
20
|
+
function firstPositionalArg(argv) {
|
|
21
|
+
const rest = Array.isArray(argv) ? argv.slice(2) : [];
|
|
22
|
+
for (let i = 0; i < rest.length; i++) {
|
|
23
|
+
const arg = rest[i];
|
|
24
|
+
if (arg === "--mode") {
|
|
25
|
+
i += 1;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (typeof arg === "string" && arg.startsWith("-")) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
return arg || null;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string[]} [argv=process.argv]
|
|
38
|
+
* @returns {boolean}
|
|
39
|
+
*/
|
|
40
|
+
export function isPermissionsMode(argv = process.argv) {
|
|
41
|
+
if (!Array.isArray(argv) || argv.length === 0) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
if (argv.includes("--mode=permissions")) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
const modeIdx = argv.indexOf("--mode");
|
|
48
|
+
if (modeIdx !== -1 && argv[modeIdx + 1] === "permissions") {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
return firstPositionalArg(argv) === "permissions";
|
|
52
|
+
}
|
|
53
|
+
|
|
11
54
|
/**
|
|
12
55
|
* @param {string[]} [argv=process.argv]
|
|
13
56
|
* @returns {boolean}
|
|
@@ -16,6 +59,10 @@ export function isIndexerMode(argv = process.argv) {
|
|
|
16
59
|
if (!Array.isArray(argv) || argv.length === 0) {
|
|
17
60
|
return false;
|
|
18
61
|
}
|
|
62
|
+
// permissions is a short-lived CLI on the same bin; it wins over indexer.
|
|
63
|
+
if (isPermissionsMode(argv)) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
19
66
|
if (argv.includes("--mode=indexer")) {
|
|
20
67
|
return true;
|
|
21
68
|
}
|
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
|
|
|
@@ -292,6 +310,33 @@ export function safeFind(searchPath, options = {}) {
|
|
|
292
310
|
* @param {object} options - spawnSync options
|
|
293
311
|
* @returns {object} { stdout, stderr, status }
|
|
294
312
|
*/
|
|
313
|
+
/**
|
|
314
|
+
* Launch a first-party app with `open -a` (argv only, never a shell).
|
|
315
|
+
* Contacts cold-start under launchd does not reliably auto-launch from
|
|
316
|
+
* `tell application "Contacts"`; `open -a Contacts` is the host-proven path.
|
|
317
|
+
*/
|
|
318
|
+
export const OPEN_APP_ALLOWLIST = Object.freeze(["Contacts", "Mail", "Messages"]);
|
|
319
|
+
|
|
320
|
+
export function safeOpenApp(appName, options = {}) {
|
|
321
|
+
const { timeout = 15000, spawn = spawnSync } = options;
|
|
322
|
+
if (!OPEN_APP_ALLOWLIST.includes(appName)) {
|
|
323
|
+
throw new Error("open -a app name is not allowed");
|
|
324
|
+
}
|
|
325
|
+
const result = spawn("open", ["-a", appName], {
|
|
326
|
+
encoding: "utf-8",
|
|
327
|
+
timeout,
|
|
328
|
+
shell: false
|
|
329
|
+
});
|
|
330
|
+
if (result.error) {
|
|
331
|
+
throw result.error;
|
|
332
|
+
}
|
|
333
|
+
if (result.status !== 0) {
|
|
334
|
+
const stderr = String(result.stderr || "").trim();
|
|
335
|
+
throw new Error(stderr || `open -a ${appName} exited ${result.status}`);
|
|
336
|
+
}
|
|
337
|
+
return String(result.stdout || "").trim();
|
|
338
|
+
}
|
|
339
|
+
|
|
295
340
|
export function safeSpawn(command, args = [], options = {}) {
|
|
296
341
|
const {
|
|
297
342
|
timeout = 30000,
|
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
|
@@ -12,10 +12,16 @@
|
|
|
12
12
|
* - An MCP stdio process delegates to the daemon when the write bridge is up.
|
|
13
13
|
* - If delegation is impossible, it runs locally and, on a TCC denial,
|
|
14
14
|
* explains the host constraint instead of reporting a generic failure.
|
|
15
|
+
* - Mini LaunchAgent / write-bridge hosts may be told to use the daemon.
|
|
16
|
+
* MacBook / Terminal / permissions CLI (no writer.sock) must not be told
|
|
17
|
+
* to start apple-tools-indexer — that path is Mini-only.
|
|
15
18
|
*
|
|
16
19
|
* These helpers are pure so the routing policy is testable off-macOS.
|
|
17
20
|
*/
|
|
18
21
|
|
|
22
|
+
import fs from "fs";
|
|
23
|
+
import { defaultSocketPath } from "./writeBridge.js";
|
|
24
|
+
|
|
19
25
|
/**
|
|
20
26
|
* Writes that macOS gates behind per-app privacy (TCC) rather than plain
|
|
21
27
|
* file permissions. All of them benefit from running in the daemon.
|
|
@@ -58,12 +64,76 @@ export function planAfterDelegation({ delivered, response }) {
|
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
67
|
+
* Mini always-on indexer / write bridge is present (writer.sock or this
|
|
68
|
+
* process is the daemon). Absent on MacBook / short-lived Terminal CLI.
|
|
63
69
|
*/
|
|
64
|
-
export function
|
|
65
|
-
|
|
66
|
-
|
|
70
|
+
export function detectLaunchAgentContext({
|
|
71
|
+
indexerMode = false,
|
|
72
|
+
bridgeAvailable = false,
|
|
73
|
+
socketPath,
|
|
74
|
+
existsSync = fs.existsSync
|
|
75
|
+
} = {}) {
|
|
76
|
+
if (indexerMode || bridgeAvailable) return true;
|
|
77
|
+
const sock = socketPath || defaultSocketPath();
|
|
78
|
+
try {
|
|
79
|
+
return Boolean(sock && existsSync(sock));
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* MacBook / Terminal / permissions CLI — no always-on indexer.
|
|
87
|
+
* Allows attach to the printed process.execPath.
|
|
88
|
+
*/
|
|
89
|
+
export function terminalAutomationAdvice(execPath = process.execPath) {
|
|
90
|
+
const printed = execPath ? ` (${execPath})` : "";
|
|
91
|
+
return (
|
|
92
|
+
`Run this from Terminal.app (short-lived CLI / MacBook host — no always-on indexer). ` +
|
|
93
|
+
`Click Allow for the printed process.execPath${printed}. ` +
|
|
94
|
+
`Check System Settings → Privacy & Security → Automation for that node → Contacts, Calendar, Mail, and Messages. ` +
|
|
95
|
+
`Do not start apple-tools-indexer.`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Mini host with a live write-bridge / LaunchAgent.
|
|
101
|
+
*/
|
|
102
|
+
export function miniLaunchAgentAdvice() {
|
|
103
|
+
return (
|
|
104
|
+
"This Mac has a Mini write-bridge / LaunchAgent. Grant the daemon's node Full Disk Access (reads) and " +
|
|
105
|
+
"Allow that node in System Settings → Privacy & Security → Automation for Mail.app, Messages.app, Contacts.app, and Calendar.app. " +
|
|
106
|
+
"Do not add node via + in the Contacts or Calendars privacy lists."
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function hostAutomationAdvice({
|
|
111
|
+
launchAgent = false,
|
|
112
|
+
execPath = process.execPath
|
|
113
|
+
} = {}) {
|
|
114
|
+
return launchAgent ? miniLaunchAgentAdvice() : terminalAutomationAdvice(execPath);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Advice appended when a local write is denied by TCC.
|
|
119
|
+
* No writer.sock / LaunchAgent → Terminal + printed execPath only.
|
|
120
|
+
*/
|
|
121
|
+
export function tccFallbackAdvice({
|
|
122
|
+
bridgeAvailable,
|
|
123
|
+
execPath = process.execPath,
|
|
124
|
+
launchAgent
|
|
125
|
+
} = {}) {
|
|
126
|
+
const mini = launchAgent === undefined ? Boolean(bridgeAvailable) : Boolean(launchAgent);
|
|
127
|
+
if (mini && bridgeAvailable) {
|
|
128
|
+
return (
|
|
129
|
+
"The indexer daemon was reachable but the write was still denied; grant the daemon's node binary Full Disk Access (reads) and " +
|
|
130
|
+
"Allow node in System Settings > Privacy & Security > Automation for Mail.app, Messages.app, Contacts.app, and Calendar.app. " +
|
|
131
|
+
"A hang or timeout on Mail compose is TCC / Automation denied, not Mail.app missing. " +
|
|
132
|
+
"Do not add node via + in the Contacts or Calendars privacy lists — those panes often have no Add button."
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
if (mini) {
|
|
136
|
+
return miniLaunchAgentAdvice();
|
|
67
137
|
}
|
|
68
|
-
return
|
|
138
|
+
return terminalAutomationAdvice(execPath);
|
|
69
139
|
}
|