@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.
- package/frockbot.json +43 -0
- package/package.json +34 -6
- package/src/agent.test.ts +380 -0
- package/src/agent.ts +548 -0
- package/src/device.test.ts +321 -0
- package/src/device.ts +413 -0
- package/src/gate.test.ts +99 -0
- package/src/gate.ts +79 -0
- package/src/index.ts +3 -0
- package/src/manifest.ts +3 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/src/device.ts
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
// What one Messages call becomes on the Mac — everything but the OS calls.
|
|
2
|
+
//
|
|
3
|
+
// This is row 57g's half of "the plan's honest floor": the SQL that reads
|
|
4
|
+
// `chat.db`, the Apple-epoch arithmetic, the row shapes, the AppleScript a send
|
|
5
|
+
// composes, and every classification decision — which outcome a missing
|
|
6
|
+
// attachment gets, what a denied permission answers, what `truncated` means —
|
|
7
|
+
// all live here, in a Package, under `bun test`. What is left for
|
|
8
|
+
// `apps/desktop/src/main` is three verbs it cannot avoid being Node for:
|
|
9
|
+
// opening a SQLite file read-only, running `osascript`, and reading bytes off a
|
|
10
|
+
// disk.
|
|
11
|
+
//
|
|
12
|
+
// Two rules this file exists to hold:
|
|
13
|
+
//
|
|
14
|
+
// 1. **The database is never written and never asked for anything but rows.**
|
|
15
|
+
// The seam takes a statement and parameters, and every statement here is a
|
|
16
|
+
// `SELECT`. A Bot's text reaches SQLite only as a bound parameter, so a
|
|
17
|
+
// message containing a quote is a message and not a query.
|
|
18
|
+
// 2. **Permissions are checked on the machine, every call.** The backend
|
|
19
|
+
// refuses on the last *report*; this refuses on what is true right now. The
|
|
20
|
+
// two are not redundant: TCC consent can be withdrawn between a Turn and
|
|
21
|
+
// the poll that carries its command.
|
|
22
|
+
import {
|
|
23
|
+
MACHINE_LIMITS_V1,
|
|
24
|
+
MACHINE_MESSAGES_LIMITS_V1,
|
|
25
|
+
type MachineMessagesCallV1,
|
|
26
|
+
type MachineMessagesPermissionsV1,
|
|
27
|
+
} from "@frockbot/machine-protocol";
|
|
28
|
+
import type { MachineCommandReportV1 } from "@frockbot/plugin-user-machine/device";
|
|
29
|
+
import type { MachineMessagesOpRunnerV1 } from "@frockbot/plugin-user-machine/device-runner";
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// The seam
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/** One row of a `chat.db` read, as SQLite hands it back. */
|
|
36
|
+
export type MachineMessagesRowV1 = Record<string, string | number | null>;
|
|
37
|
+
|
|
38
|
+
export interface MachineMessagesQueryRequestV1 {
|
|
39
|
+
sql: string;
|
|
40
|
+
parameters: Array<string | number>;
|
|
41
|
+
maxRows: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The only authority a Messages call has over the Mac.
|
|
46
|
+
*
|
|
47
|
+
* Four verbs, and none of them takes a decision. `apps/desktop` implements it
|
|
48
|
+
* with `node:sqlite`, `osascript` and `node:fs`; a plain object implements it
|
|
49
|
+
* in a test, which is why every line above it runs in CI.
|
|
50
|
+
*/
|
|
51
|
+
export interface MachineMessagesDeviceSeamV1 {
|
|
52
|
+
/** Whether macOS has granted Full Disk Access and Automation, right now. */
|
|
53
|
+
checkPermissions(
|
|
54
|
+
signal: AbortSignal,
|
|
55
|
+
): Promise<Omit<MachineMessagesPermissionsV1, "schemaVersion" | "checkedAt">>;
|
|
56
|
+
/** One read-only `SELECT` against `~/Library/Messages/chat.db`. */
|
|
57
|
+
query(
|
|
58
|
+
request: MachineMessagesQueryRequestV1,
|
|
59
|
+
signal: AbortSignal,
|
|
60
|
+
): Promise<MachineMessagesRowV1[]>;
|
|
61
|
+
/** Tell Messages.app to send. The AppleScript is composed here, not there. */
|
|
62
|
+
send(
|
|
63
|
+
request: { recipient: string; text: string },
|
|
64
|
+
signal: AbortSignal,
|
|
65
|
+
): Promise<void>;
|
|
66
|
+
/** One attachment's bytes, bounded. */
|
|
67
|
+
readFile(
|
|
68
|
+
request: { path: string; maxBytes: number },
|
|
69
|
+
signal: AbortSignal,
|
|
70
|
+
): Promise<{ bytesBase64: string; truncated: boolean }>;
|
|
71
|
+
/** The user's home directory, so a `~`-relative attachment path resolves. */
|
|
72
|
+
home(): string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// chat.db
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/** Where the Messages database lives, relative to a home directory. */
|
|
80
|
+
export function machineMessagesDatabasePathV1(home: string): string {
|
|
81
|
+
return `${home.replace(/\/$/, "")}/Library/Messages/chat.db`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Apple's epoch is 2001-01-01, and `message.date` has been nanoseconds since
|
|
86
|
+
* it for a decade — but rows written by very old macOS versions are seconds.
|
|
87
|
+
* The magnitude tells them apart, which is cheaper and more honest than
|
|
88
|
+
* guessing from an OS version the backend cannot see.
|
|
89
|
+
*/
|
|
90
|
+
export const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
|
|
91
|
+
|
|
92
|
+
export function appleDateToIsoV1(value: unknown): string | undefined {
|
|
93
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value === 0) {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
const seconds = Math.abs(value) > 1e11 ? value / 1_000_000_000 : value;
|
|
97
|
+
const at = (seconds + APPLE_EPOCH_OFFSET_SECONDS) * 1_000;
|
|
98
|
+
if (!Number.isFinite(at) || Math.abs(at) > 8.64e15) return undefined;
|
|
99
|
+
return new Date(at).toISOString();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function text(value: unknown): string | undefined {
|
|
103
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One conversation, as a tool result renders it. */
|
|
107
|
+
export interface MachineMessagesChatV1 {
|
|
108
|
+
chatId: string;
|
|
109
|
+
name?: string;
|
|
110
|
+
handle?: string;
|
|
111
|
+
lastMessageAt?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function machineMessagesChatRowV1(
|
|
115
|
+
row: MachineMessagesRowV1,
|
|
116
|
+
): MachineMessagesChatV1 {
|
|
117
|
+
return {
|
|
118
|
+
chatId: String(row.guid ?? row.chat_identifier ?? row.ROWID ?? ""),
|
|
119
|
+
...(text(row.display_name) === undefined
|
|
120
|
+
? {}
|
|
121
|
+
: { name: text(row.display_name)! }),
|
|
122
|
+
...(text(row.chat_identifier) === undefined
|
|
123
|
+
? {}
|
|
124
|
+
: { handle: text(row.chat_identifier)! }),
|
|
125
|
+
...(appleDateToIsoV1(row.last_date) === undefined
|
|
126
|
+
? {}
|
|
127
|
+
: { lastMessageAt: appleDateToIsoV1(row.last_date)! }),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** One message, as a tool result renders it. */
|
|
132
|
+
export interface MachineMessagesItemV1 {
|
|
133
|
+
rowId: number;
|
|
134
|
+
chatId?: string;
|
|
135
|
+
fromMe: boolean;
|
|
136
|
+
handle?: string;
|
|
137
|
+
text?: string;
|
|
138
|
+
at?: string;
|
|
139
|
+
attachmentId?: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function machineMessagesItemRowV1(
|
|
143
|
+
row: MachineMessagesRowV1,
|
|
144
|
+
): MachineMessagesItemV1 {
|
|
145
|
+
const rowId = typeof row.ROWID === "number" ? row.ROWID : 0;
|
|
146
|
+
return {
|
|
147
|
+
rowId,
|
|
148
|
+
...(text(row.chat_guid) === undefined
|
|
149
|
+
? {}
|
|
150
|
+
: { chatId: text(row.chat_guid)! }),
|
|
151
|
+
fromMe: row.is_from_me === 1,
|
|
152
|
+
...(text(row.handle) === undefined ? {} : { handle: text(row.handle)! }),
|
|
153
|
+
...(text(row.text) === undefined ? {} : { text: text(row.text)! }),
|
|
154
|
+
...(appleDateToIsoV1(row.date) === undefined
|
|
155
|
+
? {}
|
|
156
|
+
: { at: appleDateToIsoV1(row.date)! }),
|
|
157
|
+
...(row.attachment_id === null || row.attachment_id === undefined
|
|
158
|
+
? {}
|
|
159
|
+
: { attachmentId: String(row.attachment_id) }),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const CHAT_COLUMNS =
|
|
164
|
+
"c.ROWID AS ROWID, c.guid AS guid, c.chat_identifier AS chat_identifier, c.display_name AS display_name, MAX(m.date) AS last_date";
|
|
165
|
+
|
|
166
|
+
const MESSAGE_COLUMNS =
|
|
167
|
+
"m.ROWID AS ROWID, m.text AS text, m.is_from_me AS is_from_me, m.date AS date, h.id AS handle, c.guid AS chat_guid, (SELECT attachment_id FROM message_attachment_join WHERE message_id = m.ROWID LIMIT 1) AS attachment_id";
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The statement one call runs, and its bound parameters.
|
|
171
|
+
*
|
|
172
|
+
* Exported because it is the interesting half: a test asserts the exact SQL and
|
|
173
|
+
* the exact parameters, which is how "a Bot's text never becomes a query" is
|
|
174
|
+
* checked rather than asserted.
|
|
175
|
+
*/
|
|
176
|
+
export function machineMessagesQueryV1(
|
|
177
|
+
call: MachineMessagesCallV1,
|
|
178
|
+
): MachineMessagesQueryRequestV1 {
|
|
179
|
+
if (call.kind === "find-chats") {
|
|
180
|
+
const filter = call.query
|
|
181
|
+
? " WHERE c.display_name LIKE ?1 OR c.chat_identifier LIKE ?1"
|
|
182
|
+
: "";
|
|
183
|
+
return {
|
|
184
|
+
sql: `SELECT ${CHAT_COLUMNS} FROM chat c JOIN chat_message_join j ON j.chat_id = c.ROWID JOIN message m ON m.ROWID = j.message_id${filter} GROUP BY c.ROWID ORDER BY last_date DESC LIMIT ${call.limit}`,
|
|
185
|
+
parameters: call.query ? [`%${call.query}%`] : [],
|
|
186
|
+
maxRows: call.limit,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (call.kind === "chat-items") {
|
|
190
|
+
const paging = call.beforeRowId === undefined ? "" : " AND m.ROWID < ?2";
|
|
191
|
+
return {
|
|
192
|
+
sql: `SELECT ${MESSAGE_COLUMNS} FROM message m JOIN chat_message_join j ON j.message_id = m.ROWID JOIN chat c ON c.ROWID = j.chat_id LEFT JOIN handle h ON h.ROWID = m.handle_id WHERE (c.guid = ?1 OR c.chat_identifier = ?1)${paging} ORDER BY m.date DESC LIMIT ${call.limit}`,
|
|
193
|
+
parameters:
|
|
194
|
+
call.beforeRowId === undefined
|
|
195
|
+
? [call.chatId]
|
|
196
|
+
: [call.chatId, call.beforeRowId],
|
|
197
|
+
maxRows: call.limit,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
if (call.kind === "search") {
|
|
201
|
+
return {
|
|
202
|
+
sql: `SELECT ${MESSAGE_COLUMNS} FROM message m JOIN chat_message_join j ON j.message_id = m.ROWID JOIN chat c ON c.ROWID = j.chat_id LEFT JOIN handle h ON h.ROWID = m.handle_id WHERE m.text LIKE ?1 ORDER BY m.date DESC LIMIT ${call.limit}`,
|
|
203
|
+
parameters: [`%${call.query}%`],
|
|
204
|
+
maxRows: call.limit,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
if (call.kind === "activity") {
|
|
208
|
+
return {
|
|
209
|
+
sql: `SELECT ${MESSAGE_COLUMNS} FROM message m JOIN chat_message_join j ON j.message_id = m.ROWID JOIN chat c ON c.ROWID = j.chat_id LEFT JOIN handle h ON h.ROWID = m.handle_id ORDER BY m.date DESC LIMIT ${call.limit}`,
|
|
210
|
+
parameters: [],
|
|
211
|
+
maxRows: call.limit,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
if (call.kind === "fetch-attachment") {
|
|
215
|
+
return {
|
|
216
|
+
sql: "SELECT a.ROWID AS ROWID, a.guid AS guid, a.filename AS filename, a.mime_type AS mime_type, a.total_bytes AS total_bytes FROM attachment a WHERE CAST(a.ROWID AS TEXT) = ?1 OR a.guid = ?1 LIMIT 1",
|
|
217
|
+
parameters: [call.attachmentId],
|
|
218
|
+
maxRows: 1,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
throw new Error(`${call.kind} is not a chat.db read`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** `~/…` as `chat.db` stores it, resolved against the agent's own home. */
|
|
225
|
+
export function machineMessagesAttachmentPathV1(
|
|
226
|
+
filename: string,
|
|
227
|
+
home: string,
|
|
228
|
+
): string {
|
|
229
|
+
if (filename.startsWith("~/")) {
|
|
230
|
+
return `${home.replace(/\/$/, "")}/${filename.slice(2)}`;
|
|
231
|
+
}
|
|
232
|
+
return filename;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Sending
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* AppleScript has no parameter binding, so the escape is the whole safety
|
|
241
|
+
* story: a double quote or a backslash in somebody's message must not be able
|
|
242
|
+
* to close the string and become script. Everything else — including a newline,
|
|
243
|
+
* which AppleScript string literals accept — is left exactly as the user wrote
|
|
244
|
+
* it, because a send that silently rewrote the message would be worse than one
|
|
245
|
+
* that refused.
|
|
246
|
+
*/
|
|
247
|
+
export function escapeAppleScriptStringV1(value: string): string {
|
|
248
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The script one send runs. Composed here so its escaping is tested here. */
|
|
252
|
+
export function machineMessagesSendScriptV1(
|
|
253
|
+
recipient: string,
|
|
254
|
+
body: string,
|
|
255
|
+
): string {
|
|
256
|
+
return [
|
|
257
|
+
'tell application "Messages"',
|
|
258
|
+
" set targetService to 1st account whose service type = iMessage",
|
|
259
|
+
` set targetBuddy to participant "${escapeAppleScriptStringV1(recipient)}" of targetService`,
|
|
260
|
+
` send "${escapeAppleScriptStringV1(body)}" to targetBuddy`,
|
|
261
|
+
"end tell",
|
|
262
|
+
].join("\n");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// The runner
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
export interface MachineMessagesDeviceRunnerOptionsV1 {
|
|
270
|
+
seam: MachineMessagesDeviceSeamV1;
|
|
271
|
+
now?(): number;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The refusal wording, once. "Refused:" is load-bearing — `plugin-audit`'s
|
|
276
|
+
* `outcomeFor` classifies on that prefix, so a permission the user never
|
|
277
|
+
* granted reads as a decision rather than a failure of their Mac.
|
|
278
|
+
*/
|
|
279
|
+
export function machineMessagesRefusalV1(reason: string): string {
|
|
280
|
+
return `Refused: ${reason}`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export const MACHINE_MESSAGES_FULL_DISK_REFUSAL_V1 =
|
|
284
|
+
"macOS has not granted FrockBot Full Disk Access on this Mac, so Messages history cannot be read. Grant it in System Settings › Privacy & Security › Full Disk Access and restart FrockBot.";
|
|
285
|
+
|
|
286
|
+
export const MACHINE_MESSAGES_AUTOMATION_REFUSAL_V1 =
|
|
287
|
+
"macOS has not granted FrockBot Automation rights over Messages.app on this Mac, so nothing can be sent. Grant it in System Settings › Privacy & Security › Automation.";
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* One Messages call, run.
|
|
291
|
+
*
|
|
292
|
+
* Never throws: a thrown handler would leave a claimed command unanswered until
|
|
293
|
+
* its lease lapsed, and an `error` outcome a Bot can read is strictly better
|
|
294
|
+
* than silence.
|
|
295
|
+
*/
|
|
296
|
+
export function createMachineMessagesDeviceRunnerV1(
|
|
297
|
+
options: MachineMessagesDeviceRunnerOptionsV1,
|
|
298
|
+
): MachineMessagesOpRunnerV1 {
|
|
299
|
+
const at = (): string =>
|
|
300
|
+
new Date(options.now?.() ?? Date.now()).toISOString();
|
|
301
|
+
const refuse = (reason: string): MachineCommandReportV1 => ({
|
|
302
|
+
finishedAt: at(),
|
|
303
|
+
outcome: "refused",
|
|
304
|
+
truncated: false,
|
|
305
|
+
message: machineMessagesRefusalV1(reason).slice(
|
|
306
|
+
0,
|
|
307
|
+
MACHINE_LIMITS_V1.message,
|
|
308
|
+
),
|
|
309
|
+
});
|
|
310
|
+
const ok = (
|
|
311
|
+
body: Record<string, unknown>,
|
|
312
|
+
extra: Partial<MachineCommandReportV1> = {},
|
|
313
|
+
): MachineCommandReportV1 => ({
|
|
314
|
+
finishedAt: at(),
|
|
315
|
+
outcome: "ok",
|
|
316
|
+
truncated: false,
|
|
317
|
+
stdout: JSON.stringify(body),
|
|
318
|
+
...extra,
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
return async (call, signal) => {
|
|
322
|
+
try {
|
|
323
|
+
const observed = await options.seam.checkPermissions(signal);
|
|
324
|
+
const permissions: MachineMessagesPermissionsV1 = {
|
|
325
|
+
schemaVersion: 1,
|
|
326
|
+
fullDiskAccess: observed.fullDiskAccess,
|
|
327
|
+
automation: observed.automation,
|
|
328
|
+
checkedAt: at(),
|
|
329
|
+
...(observed.detail === undefined
|
|
330
|
+
? {}
|
|
331
|
+
: {
|
|
332
|
+
detail: observed.detail.slice(
|
|
333
|
+
0,
|
|
334
|
+
MACHINE_MESSAGES_LIMITS_V1.detail,
|
|
335
|
+
),
|
|
336
|
+
}),
|
|
337
|
+
};
|
|
338
|
+
// The check itself always answers. It is how a machine stops being
|
|
339
|
+
// unknown to the backend, so refusing it for want of a permission would
|
|
340
|
+
// be a gate that can never be opened.
|
|
341
|
+
if (call.kind === "check-permissions") {
|
|
342
|
+
return ok({ kind: "permissions", permissions });
|
|
343
|
+
}
|
|
344
|
+
if (!permissions.fullDiskAccess) {
|
|
345
|
+
return refuse(MACHINE_MESSAGES_FULL_DISK_REFUSAL_V1);
|
|
346
|
+
}
|
|
347
|
+
if (call.kind === "send") {
|
|
348
|
+
if (!permissions.automation) {
|
|
349
|
+
return refuse(MACHINE_MESSAGES_AUTOMATION_REFUSAL_V1);
|
|
350
|
+
}
|
|
351
|
+
await options.seam.send(
|
|
352
|
+
{ recipient: call.to, text: call.text },
|
|
353
|
+
signal,
|
|
354
|
+
);
|
|
355
|
+
return ok({ kind: "sent", to: call.to, at: at() });
|
|
356
|
+
}
|
|
357
|
+
const rows = await options.seam.query(
|
|
358
|
+
machineMessagesQueryV1(call),
|
|
359
|
+
signal,
|
|
360
|
+
);
|
|
361
|
+
if (call.kind === "find-chats") {
|
|
362
|
+
return ok({
|
|
363
|
+
kind: "chats",
|
|
364
|
+
chats: rows.map((row) => machineMessagesChatRowV1(row)),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
if (call.kind === "fetch-attachment") {
|
|
368
|
+
const row = rows[0];
|
|
369
|
+
const filename = row === undefined ? undefined : text(row.filename);
|
|
370
|
+
if (!filename) {
|
|
371
|
+
return refuse(
|
|
372
|
+
`no attachment "${call.attachmentId}" is in this Mac's Messages database`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
const file = await options.seam.readFile(
|
|
376
|
+
{
|
|
377
|
+
path: machineMessagesAttachmentPathV1(
|
|
378
|
+
filename,
|
|
379
|
+
options.seam.home(),
|
|
380
|
+
),
|
|
381
|
+
maxBytes: call.maxBytes,
|
|
382
|
+
},
|
|
383
|
+
signal,
|
|
384
|
+
);
|
|
385
|
+
return ok(
|
|
386
|
+
{
|
|
387
|
+
kind: "attachment",
|
|
388
|
+
attachmentId: call.attachmentId,
|
|
389
|
+
...(text(row?.mime_type) === undefined
|
|
390
|
+
? {}
|
|
391
|
+
: { mimeType: text(row?.mime_type)! }),
|
|
392
|
+
truncated: file.truncated,
|
|
393
|
+
},
|
|
394
|
+
{ truncated: file.truncated, bytesBase64: file.bytesBase64 },
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
return ok({
|
|
398
|
+
kind: "items",
|
|
399
|
+
items: rows.map((row) => machineMessagesItemRowV1(row)),
|
|
400
|
+
});
|
|
401
|
+
} catch (error) {
|
|
402
|
+
return {
|
|
403
|
+
finishedAt: at(),
|
|
404
|
+
outcome: "error",
|
|
405
|
+
truncated: false,
|
|
406
|
+
message: (error instanceof Error ? error.message : String(error)).slice(
|
|
407
|
+
0,
|
|
408
|
+
MACHINE_LIMITS_V1.message,
|
|
409
|
+
),
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
}
|
package/src/gate.test.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// The two gates that decide whether the Messages tools exist at all.
|
|
2
|
+
import { describe, expect, test } from "bun:test";
|
|
3
|
+
import type { MachineListEntryV1 } from "@frockbot/machine-protocol";
|
|
4
|
+
import {
|
|
5
|
+
MACHINE_MESSAGES_SETTING_V1,
|
|
6
|
+
machineMessagesEnabledV1,
|
|
7
|
+
machineMessagesGateV1,
|
|
8
|
+
} from "./gate.js";
|
|
9
|
+
|
|
10
|
+
const NOW = "2026-09-01T00:00:00.000Z";
|
|
11
|
+
|
|
12
|
+
function entry(
|
|
13
|
+
overrides: Partial<MachineListEntryV1> = {},
|
|
14
|
+
): MachineListEntryV1 {
|
|
15
|
+
return {
|
|
16
|
+
machineId: "mac-1",
|
|
17
|
+
label: "Tims-M5-MacBook-Pro.local",
|
|
18
|
+
platform: "macos",
|
|
19
|
+
capabilities: ["exec", "files", "messages"],
|
|
20
|
+
connected: true,
|
|
21
|
+
lastSeenAt: NOW,
|
|
22
|
+
registeredAt: NOW,
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("the feature gate", () => {
|
|
28
|
+
test("a setting nobody has touched is off", () => {
|
|
29
|
+
expect(machineMessagesEnabledV1(undefined)).toBe(false);
|
|
30
|
+
expect(machineMessagesEnabledV1({})).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("only true is on", () => {
|
|
34
|
+
expect(
|
|
35
|
+
machineMessagesEnabledV1({ [MACHINE_MESSAGES_SETTING_V1]: true }),
|
|
36
|
+
).toBe(true);
|
|
37
|
+
for (const value of ["true", 1, "", 0, false] as const) {
|
|
38
|
+
expect(
|
|
39
|
+
machineMessagesEnabledV1({ [MACHINE_MESSAGES_SETTING_V1]: value }),
|
|
40
|
+
).toBe(false);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("off is off whatever the registry holds", () => {
|
|
45
|
+
expect(
|
|
46
|
+
machineMessagesGateV1({ enabled: false, machines: [entry()] }),
|
|
47
|
+
).toEqual({ status: "off" });
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("the capability gate", () => {
|
|
52
|
+
test("a connected macOS machine reporting messages opens it", () => {
|
|
53
|
+
expect(
|
|
54
|
+
machineMessagesGateV1({ enabled: true, machines: [entry()] }),
|
|
55
|
+
).toEqual({ status: "ready", machineIds: ["mac-1"] });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("every way a machine fails to qualify", () => {
|
|
59
|
+
const refused: Array<Partial<MachineListEntryV1>> = [
|
|
60
|
+
// Never reported the capability — the agent had no handlers behind it.
|
|
61
|
+
{ capabilities: ["exec", "files"] },
|
|
62
|
+
// Not a Mac. The protocol refuses the claim at enrollment; this refuses
|
|
63
|
+
// it again, because a registry row can outlive the build that wrote it.
|
|
64
|
+
{ platform: "linux", capabilities: ["exec", "files"] },
|
|
65
|
+
// The laptop is asleep. A command has nowhere to go.
|
|
66
|
+
{ connected: false },
|
|
67
|
+
// Revoked. The row is evidence, not a machine.
|
|
68
|
+
{ revokedAt: NOW },
|
|
69
|
+
];
|
|
70
|
+
for (const overrides of refused) {
|
|
71
|
+
expect(
|
|
72
|
+
machineMessagesGateV1({
|
|
73
|
+
enabled: true,
|
|
74
|
+
machines: [entry(overrides)],
|
|
75
|
+
}),
|
|
76
|
+
).toEqual({ status: "no-machine" });
|
|
77
|
+
}
|
|
78
|
+
expect(machineMessagesGateV1({ enabled: true, machines: [] })).toEqual({
|
|
79
|
+
status: "no-machine",
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("one qualifying machine among several is enough, and only it is named", () => {
|
|
84
|
+
expect(
|
|
85
|
+
machineMessagesGateV1({
|
|
86
|
+
enabled: true,
|
|
87
|
+
machines: [
|
|
88
|
+
entry({
|
|
89
|
+
machineId: "linux-1",
|
|
90
|
+
platform: "linux",
|
|
91
|
+
capabilities: ["exec"],
|
|
92
|
+
}),
|
|
93
|
+
entry({ machineId: "mac-asleep", connected: false }),
|
|
94
|
+
entry({ machineId: "mac-2" }),
|
|
95
|
+
],
|
|
96
|
+
}),
|
|
97
|
+
).toEqual({ status: "ready", machineIds: ["mac-2"] });
|
|
98
|
+
});
|
|
99
|
+
});
|
package/src/gate.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Row 57g's gate, as one pure function.
|
|
2
|
+
//
|
|
3
|
+
// The register gates the Messages tools twice — "behind a feature gate and a
|
|
4
|
+
// permission check" — and the transport adds a third that falls out of what a
|
|
5
|
+
// laptop is: the tools mean nothing without a connected Mac to run them on. So
|
|
6
|
+
// there are three independent answers, and they are deliberately different
|
|
7
|
+
// *kinds* of answer:
|
|
8
|
+
//
|
|
9
|
+
// 1. **The User setting** (`machines.messagesEnabled`, GrokBot's
|
|
10
|
+
// `gates.messagesTools`). Off is the default, and off means the tools are
|
|
11
|
+
// never registered — absent from the catalog rather than present and
|
|
12
|
+
// refusing. A capability a Bot cannot see is one it cannot be talked into
|
|
13
|
+
// trying.
|
|
14
|
+
// 2. **The device capability.** The agent reports `messages` at enrollment,
|
|
15
|
+
// and only a `platform: "macos"` agent may: the protocol's own enrollment
|
|
16
|
+
// decoder refuses it from anything else, and the desktop agent claims it
|
|
17
|
+
// only when the shell wired handlers behind it. No connected macOS machine
|
|
18
|
+
// reporting it ⇒ no registration.
|
|
19
|
+
// 3. **The OS permission** (`CheckIMessagePermissions`). Full Disk Access for
|
|
20
|
+
// `chat.db` and Automation rights over Messages.app are the User's to grant
|
|
21
|
+
// in System Settings; the backend can only ever *report* them. That gate is
|
|
22
|
+
// per call rather than per catalog, because it can change between one Turn
|
|
23
|
+
// and the next, and it lives in `./agent.ts` beside the call it refuses.
|
|
24
|
+
//
|
|
25
|
+
// This file holds the first two, which decide whether the tools exist at all.
|
|
26
|
+
// It is pure so the gate is asserted rather than inferred from a running app.
|
|
27
|
+
import type { MachineListEntryV1 } from "@frockbot/machine-protocol";
|
|
28
|
+
|
|
29
|
+
/** The Package setting id, as the manifest declares it. */
|
|
30
|
+
export const MACHINE_MESSAGES_SETTING_V1 = "messages-enabled";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The setting's value, defaulting to **off**.
|
|
34
|
+
*
|
|
35
|
+
* A setting nobody has touched is off, and anything that is not `true` is off:
|
|
36
|
+
* reaching into somebody's messages is not a thing to do on a value the
|
|
37
|
+
* resolver could not make sense of.
|
|
38
|
+
*/
|
|
39
|
+
export function machineMessagesEnabledV1(
|
|
40
|
+
values: Readonly<Record<string, string | number | boolean>> | undefined,
|
|
41
|
+
): boolean {
|
|
42
|
+
return values?.[MACHINE_MESSAGES_SETTING_V1] === true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type MachineMessagesGateV1 =
|
|
46
|
+
| { status: "off" }
|
|
47
|
+
| { status: "no-machine" }
|
|
48
|
+
| { status: "ready"; machineIds: string[] };
|
|
49
|
+
|
|
50
|
+
/** Whether one registry row can run a Messages call right now. */
|
|
51
|
+
export function machineMessagesCandidateV1(entry: MachineListEntryV1): boolean {
|
|
52
|
+
return (
|
|
53
|
+
entry.revokedAt === undefined &&
|
|
54
|
+
entry.connected &&
|
|
55
|
+
entry.platform === "macos" &&
|
|
56
|
+
entry.capabilities.includes("messages")
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether the Messages tools are registered for this Turn, and against which
|
|
62
|
+
* machines.
|
|
63
|
+
*
|
|
64
|
+
* `machineIds` is a list rather than a single id because the tools take a
|
|
65
|
+
* `machineId` like every other machine tool: the Bot chooses from
|
|
66
|
+
* `machine_list`, and this is only the fact that at least one choice exists.
|
|
67
|
+
*/
|
|
68
|
+
export function machineMessagesGateV1(input: {
|
|
69
|
+
enabled: boolean;
|
|
70
|
+
machines: readonly MachineListEntryV1[];
|
|
71
|
+
}): MachineMessagesGateV1 {
|
|
72
|
+
if (!input.enabled) return { status: "off" };
|
|
73
|
+
const machineIds = input.machines
|
|
74
|
+
.filter((entry) => machineMessagesCandidateV1(entry))
|
|
75
|
+
.map((entry) => entry.machineId);
|
|
76
|
+
return machineIds.length === 0
|
|
77
|
+
? { status: "no-machine" }
|
|
78
|
+
: { status: "ready", machineIds };
|
|
79
|
+
}
|
package/src/index.ts
ADDED
package/src/manifest.ts
ADDED
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"allowImportingTsExtensions": true,
|
|
7
|
+
"resolveJsonModule": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"lib": ["ES2023", "DOM"],
|
|
12
|
+
"types": ["bun"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*.ts"]
|
|
15
|
+
}
|
package/README.md
DELETED