@frockbot/plugin-machine-messages 0.0.0 → 0.1.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.
@@ -0,0 +1,321 @@
1
+ // What one Messages call becomes on the Mac, proved without a Mac.
2
+ import { describe, expect, test } from "bun:test";
3
+ import type { MachineMessagesCallV1 } from "@frockbot/machine-protocol";
4
+ import {
5
+ MACHINE_MESSAGES_AUTOMATION_REFUSAL_V1,
6
+ MACHINE_MESSAGES_FULL_DISK_REFUSAL_V1,
7
+ appleDateToIsoV1,
8
+ createMachineMessagesDeviceRunnerV1,
9
+ escapeAppleScriptStringV1,
10
+ machineMessagesAttachmentPathV1,
11
+ machineMessagesChatRowV1,
12
+ machineMessagesDatabasePathV1,
13
+ machineMessagesItemRowV1,
14
+ machineMessagesQueryV1,
15
+ machineMessagesSendScriptV1,
16
+ type MachineMessagesDeviceSeamV1,
17
+ type MachineMessagesQueryRequestV1,
18
+ type MachineMessagesRowV1,
19
+ } from "./device.js";
20
+
21
+ const NOW = Date.parse("2026-09-01T00:00:00.000Z");
22
+ const SIGNAL = new AbortController().signal;
23
+
24
+ interface Recorded {
25
+ queries: MachineMessagesQueryRequestV1[];
26
+ sent: Array<{ recipient: string; text: string }>;
27
+ reads: Array<{ path: string; maxBytes: number }>;
28
+ }
29
+
30
+ function seamFor(options: {
31
+ permissions?: {
32
+ fullDiskAccess: boolean;
33
+ automation: boolean;
34
+ detail?: string;
35
+ };
36
+ rows?: MachineMessagesRowV1[];
37
+ file?: { bytesBase64: string; truncated: boolean };
38
+ throws?: Error;
39
+ }): { seam: MachineMessagesDeviceSeamV1; recorded: Recorded } {
40
+ const recorded: Recorded = { queries: [], sent: [], reads: [] };
41
+ const seam: MachineMessagesDeviceSeamV1 = {
42
+ checkPermissions: async () =>
43
+ options.permissions ?? { fullDiskAccess: true, automation: true },
44
+ query: async (request) => {
45
+ recorded.queries.push(request);
46
+ if (options.throws) throw options.throws;
47
+ return options.rows ?? [];
48
+ },
49
+ send: async (request) => {
50
+ recorded.sent.push(request);
51
+ if (options.throws) throw options.throws;
52
+ },
53
+ readFile: async (request) => {
54
+ recorded.reads.push(request);
55
+ return options.file ?? { bytesBase64: "", truncated: false };
56
+ },
57
+ home: () => "/Users/tim",
58
+ };
59
+ return { seam, recorded };
60
+ }
61
+
62
+ function run(call: MachineMessagesCallV1, seam: MachineMessagesDeviceSeamV1) {
63
+ return createMachineMessagesDeviceRunnerV1({ seam, now: () => NOW })(
64
+ call,
65
+ SIGNAL,
66
+ );
67
+ }
68
+
69
+ function body(stdout: string | undefined): Record<string, unknown> {
70
+ return JSON.parse(stdout ?? "{}") as Record<string, unknown>;
71
+ }
72
+
73
+ describe("chat.db arithmetic", () => {
74
+ test("the database is where macOS keeps it", () => {
75
+ expect(machineMessagesDatabasePathV1("/Users/tim")).toBe(
76
+ "/Users/tim/Library/Messages/chat.db",
77
+ );
78
+ expect(machineMessagesDatabasePathV1("/Users/tim/")).toBe(
79
+ "/Users/tim/Library/Messages/chat.db",
80
+ );
81
+ });
82
+
83
+ test("Apple's epoch is read in both the shapes chat.db writes", () => {
84
+ // Nanoseconds since 2001-01-01, which is what a modern macOS writes.
85
+ expect(appleDateToIsoV1(789_000_000_000_000_000)).toBe(
86
+ new Date((789_000_000 + 978_307_200) * 1000).toISOString(),
87
+ );
88
+ // Seconds, which very old rows carry.
89
+ expect(appleDateToIsoV1(789_000_000)).toBe(
90
+ new Date((789_000_000 + 978_307_200) * 1000).toISOString(),
91
+ );
92
+ // A row with no date is a row with no date, not 2001.
93
+ expect(appleDateToIsoV1(0)).toBeUndefined();
94
+ expect(appleDateToIsoV1(null)).toBeUndefined();
95
+ expect(appleDateToIsoV1("yesterday")).toBeUndefined();
96
+ expect(appleDateToIsoV1(Number.POSITIVE_INFINITY)).toBeUndefined();
97
+ });
98
+
99
+ test("a `~` attachment path resolves against this account's home", () => {
100
+ expect(
101
+ machineMessagesAttachmentPathV1(
102
+ "~/Library/Messages/Attachments/a.jpg",
103
+ "/Users/tim",
104
+ ),
105
+ ).toBe("/Users/tim/Library/Messages/Attachments/a.jpg");
106
+ expect(machineMessagesAttachmentPathV1("/tmp/a.jpg", "/Users/tim")).toBe(
107
+ "/tmp/a.jpg",
108
+ );
109
+ });
110
+
111
+ test("rows become the shapes a tool result renders", () => {
112
+ expect(
113
+ machineMessagesChatRowV1({
114
+ ROWID: 3,
115
+ guid: "iMessage;-;+61400000000",
116
+ chat_identifier: "+61400000000",
117
+ display_name: "Mum",
118
+ last_date: 789_000_000_000_000_000,
119
+ }),
120
+ ).toEqual({
121
+ chatId: "iMessage;-;+61400000000",
122
+ name: "Mum",
123
+ handle: "+61400000000",
124
+ lastMessageAt: new Date((789_000_000 + 978_307_200) * 1000).toISOString(),
125
+ });
126
+ expect(
127
+ machineMessagesItemRowV1({
128
+ ROWID: 91,
129
+ text: "on my way",
130
+ is_from_me: 1,
131
+ date: 789_000_000_000_000_000,
132
+ handle: null,
133
+ chat_guid: "iMessage;-;+61400000000",
134
+ attachment_id: 12,
135
+ }),
136
+ ).toEqual({
137
+ rowId: 91,
138
+ chatId: "iMessage;-;+61400000000",
139
+ fromMe: true,
140
+ text: "on my way",
141
+ at: new Date((789_000_000 + 978_307_200) * 1000).toISOString(),
142
+ attachmentId: "12",
143
+ });
144
+ });
145
+ });
146
+
147
+ describe("the statements", () => {
148
+ test("a search term is a bound parameter and never SQL", () => {
149
+ const query = machineMessagesQueryV1({
150
+ kind: "search",
151
+ query: "'; DROP TABLE message; --",
152
+ limit: 5,
153
+ });
154
+ expect(query.sql).not.toContain("DROP");
155
+ expect(query.parameters).toEqual(["%'; DROP TABLE message; --%"]);
156
+ expect(query.maxRows).toBe(5);
157
+ expect(query.sql.startsWith("SELECT ")).toBe(true);
158
+ });
159
+
160
+ test("every read is a SELECT, and the row bound is the call's", () => {
161
+ const calls: MachineMessagesCallV1[] = [
162
+ { kind: "find-chats", limit: 7 },
163
+ { kind: "find-chats", query: "mum", limit: 7 },
164
+ { kind: "chat-items", chatId: "chat-1", limit: 7 },
165
+ { kind: "chat-items", chatId: "chat-1", limit: 7, beforeRowId: 40 },
166
+ { kind: "search", query: "x", limit: 7 },
167
+ { kind: "activity", limit: 7 },
168
+ ];
169
+ for (const call of calls) {
170
+ const query = machineMessagesQueryV1(call);
171
+ expect(query.sql.startsWith("SELECT ")).toBe(true);
172
+ expect(query.sql).not.toMatch(/\b(INSERT|UPDATE|DELETE|ATTACH|PRAGMA)\b/);
173
+ expect(query.maxRows).toBe(7);
174
+ }
175
+ // Paging binds the row id rather than interpolating it.
176
+ expect(
177
+ machineMessagesQueryV1({
178
+ kind: "chat-items",
179
+ chatId: "chat-1",
180
+ limit: 7,
181
+ beforeRowId: 40,
182
+ }).parameters,
183
+ ).toEqual(["chat-1", 40]);
184
+ });
185
+
186
+ test("a send is not a chat.db read", () => {
187
+ expect(() =>
188
+ machineMessagesQueryV1({ kind: "send", to: "a", text: "b" }),
189
+ ).toThrow(/not a chat.db read/);
190
+ });
191
+ });
192
+
193
+ describe("AppleScript", () => {
194
+ test("a quote in somebody's message cannot close the string", () => {
195
+ expect(escapeAppleScriptStringV1('say "hi" \\ now')).toBe(
196
+ 'say \\"hi\\" \\\\ now',
197
+ );
198
+ const script = machineMessagesSendScriptV1("+61400000000", 'he said "no"');
199
+ expect(script).toContain('send "he said \\"no\\"" to targetBuddy');
200
+ expect(
201
+ script.split("\n").filter((line) => line.includes("send")).length,
202
+ ).toBe(1);
203
+ });
204
+
205
+ test("a newline in the message stays a newline", () => {
206
+ expect(escapeAppleScriptStringV1("one\ntwo")).toBe("one\ntwo");
207
+ });
208
+ });
209
+
210
+ describe("the runner", () => {
211
+ test("a permission check always answers, and reports both flags", async () => {
212
+ const { seam } = seamFor({
213
+ permissions: { fullDiskAccess: false, automation: false, detail: "nope" },
214
+ });
215
+ const report = await run({ kind: "check-permissions" }, seam);
216
+ expect(report.outcome).toBe("ok");
217
+ expect(body(report.stdout)).toEqual({
218
+ kind: "permissions",
219
+ permissions: {
220
+ schemaVersion: 1,
221
+ fullDiskAccess: false,
222
+ automation: false,
223
+ checkedAt: "2026-09-01T00:00:00.000Z",
224
+ detail: "nope",
225
+ },
226
+ });
227
+ });
228
+
229
+ test("no Full Disk Access refuses every read, visibly, and queries nothing", async () => {
230
+ const { seam, recorded } = seamFor({
231
+ permissions: { fullDiskAccess: false, automation: true },
232
+ });
233
+ const report = await run({ kind: "activity", limit: 5 }, seam);
234
+ expect(report.outcome).toBe("refused");
235
+ expect(report.message).toBe(
236
+ `Refused: ${MACHINE_MESSAGES_FULL_DISK_REFUSAL_V1}`,
237
+ );
238
+ expect(recorded.queries).toEqual([]);
239
+ });
240
+
241
+ test("no Automation refuses a send, and sends nothing", async () => {
242
+ const { seam, recorded } = seamFor({
243
+ permissions: { fullDiskAccess: true, automation: false },
244
+ });
245
+ const report = await run({ kind: "send", to: "+61", text: "hi" }, seam);
246
+ expect(report.outcome).toBe("refused");
247
+ expect(report.message).toBe(
248
+ `Refused: ${MACHINE_MESSAGES_AUTOMATION_REFUSAL_V1}`,
249
+ );
250
+ expect(recorded.sent).toEqual([]);
251
+ });
252
+
253
+ test("a read returns its rows as data", async () => {
254
+ const { seam, recorded } = seamFor({
255
+ rows: [
256
+ {
257
+ ROWID: 3,
258
+ guid: "chat-1",
259
+ chat_identifier: "+61400000000",
260
+ display_name: "Mum",
261
+ last_date: 789_000_000_000_000_000,
262
+ },
263
+ ],
264
+ });
265
+ const report = await run({ kind: "find-chats", limit: 5 }, seam);
266
+ expect(report.outcome).toBe("ok");
267
+ expect(body(report.stdout).kind).toBe("chats");
268
+ expect(recorded.queries).toHaveLength(1);
269
+ });
270
+
271
+ test("a send that is permitted reaches Messages.app once", async () => {
272
+ const { seam, recorded } = seamFor({});
273
+ const report = await run({ kind: "send", to: "+61", text: "hi" }, seam);
274
+ expect(report.outcome).toBe("ok");
275
+ expect(recorded.sent).toEqual([{ recipient: "+61", text: "hi" }]);
276
+ expect(body(report.stdout).kind).toBe("sent");
277
+ });
278
+
279
+ test("an attachment is read off the disk at the path chat.db named", async () => {
280
+ const { seam, recorded } = seamFor({
281
+ rows: [
282
+ {
283
+ ROWID: 12,
284
+ guid: "att-1",
285
+ filename: "~/Library/Messages/Attachments/a.jpg",
286
+ mime_type: "image/jpeg",
287
+ total_bytes: 4,
288
+ },
289
+ ],
290
+ file: { bytesBase64: "AAAA", truncated: true },
291
+ });
292
+ const report = await run(
293
+ { kind: "fetch-attachment", attachmentId: "12", maxBytes: 1024 },
294
+ seam,
295
+ );
296
+ expect(report.outcome).toBe("ok");
297
+ expect(report.bytesBase64).toBe("AAAA");
298
+ expect(report.truncated).toBe(true);
299
+ expect(recorded.reads).toEqual([
300
+ { path: "/Users/tim/Library/Messages/Attachments/a.jpg", maxBytes: 1024 },
301
+ ]);
302
+ });
303
+
304
+ test("an attachment that is not there is a refusal, not an error", async () => {
305
+ const { seam, recorded } = seamFor({ rows: [] });
306
+ const report = await run(
307
+ { kind: "fetch-attachment", attachmentId: "99", maxBytes: 1024 },
308
+ seam,
309
+ );
310
+ expect(report.outcome).toBe("refused");
311
+ expect(report.message).toContain("Refused:");
312
+ expect(recorded.reads).toEqual([]);
313
+ });
314
+
315
+ test("a thrown seam is an answer, never a thrown runner", async () => {
316
+ const { seam } = seamFor({ throws: new Error("database is locked") });
317
+ const report = await run({ kind: "activity", limit: 5 }, seam);
318
+ expect(report.outcome).toBe("error");
319
+ expect(report.message).toContain("database is locked");
320
+ });
321
+ });