@frockbot/machine-protocol 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,1540 @@
1
+ /**
2
+ * The versioned wire protocol between FrockBot's backend and a registered
3
+ * machine of the User's — the parity register's "registered Mac" (§2.16).
4
+ *
5
+ * The machine is not the Computer and not the Workspace: it is a separate
6
+ * filesystem the backend can never dial. `127.0.0.1` from the box is the box,
7
+ * and a laptop behind NAT has no inbound address, so every exchange here is
8
+ * one the *machine* starts: it enrolls, it long-polls for work, it claims a
9
+ * command, it posts a result. Nothing in this module opens a socket, reads a
10
+ * clock it was not handed, or touches storage; it is DTOs, their decoders, and
11
+ * the arithmetic that turns a stored timestamp into `connected`.
12
+ *
13
+ * "Cross-runtime communication uses narrow, versioned DTOs, and every inbound
14
+ * value is decoded at its seam." Three runtimes import this one module and
15
+ * none keeps a second copy: the gateway Worker decodes what the machine sends,
16
+ * the User Durable Object decodes what the gateway forwards, and the desktop
17
+ * agent decodes what the backend answers.
18
+ *
19
+ * Every decoder is exact-key: a field the schema does not declare is a
20
+ * refusal, not a field that is ignored, so a caller cannot smuggle one past a
21
+ * seam and have a later version start honouring it.
22
+ */
23
+
24
+ /** Bumped only for a breaking change; a new command op is additive. */
25
+ export const MACHINE_PROTOCOL_VERSION = 1;
26
+
27
+ /**
28
+ * Every bound the protocol enforces, declared once so the gateway, the Durable
29
+ * Object, the desktop agent and their tests all refuse at the same size, and
30
+ * so changing a limit is one edit at one seam.
31
+ */
32
+ export const MACHINE_LIMITS_V1 = {
33
+ /** Identifiers: machine, user, bot, run, command, approval. */
34
+ identifier: 200,
35
+ /** The machine's own name for itself — a hostname, user-editable later. */
36
+ label: 200,
37
+ /** Reported agent version, e.g. `0.4.1`. */
38
+ agentVersion: 64,
39
+ /**
40
+ * A pairing code as it is presented on enrollment. It is a *signed token*
41
+ * carrying the User it was minted for — enrollment runs before gateway
42
+ * authentication, so the code is the only thing that can name a Durable
43
+ * Object — which is why the bound is a token's and not a passphrase's.
44
+ */
45
+ pairingCode: 512,
46
+ /** Capabilities one agent may report. */
47
+ capabilities: 8,
48
+ /** A path on the machine. Not a Computer path: no absolute-form rule. */
49
+ path: 4_096,
50
+ /** A Workspace path a copy names on the FrockBot side. */
51
+ workspacePath: 4_096,
52
+ /** One shell command line. */
53
+ command: 16_384,
54
+ /** Working directory for one exec. */
55
+ cwd: 4_096,
56
+ /** Failure or refusal text carried on a result. */
57
+ message: 2_048,
58
+ /** The most output one exec may return, and the ceiling it may ask for. */
59
+ outputBytes: 1_024 * 1_024,
60
+ /** The most one file read may return, and the ceiling it may ask for. */
61
+ readBytes: 8 * 1_024 * 1_024,
62
+ /** Base64 payload on a result, encoded length. */
63
+ payloadBase64: 16 * 1_024 * 1_024,
64
+ /** The whole JSON request body, at any machine route. */
65
+ requestBytes: 16 * 1_024 * 1_024,
66
+ /** The longest an exec may run, and the ceiling a request may ask for. */
67
+ execTimeoutMs: 600_000,
68
+ /** Commands one machine may hold queued at once. */
69
+ maxQueue: 16,
70
+ /** Machines one User may hold registered at once. */
71
+ maxMachinesPerUser: 8,
72
+ /** Commands one User may dispatch across all machines in a day. */
73
+ commandsPerDay: 500,
74
+ /** How stale `lastSeenAt` may be before a machine reads as disconnected. */
75
+ presenceTtlMs: 90_000,
76
+ /** How long a pairing offer stands before it is spent or expires. */
77
+ pairingTtlMs: 5 * 60_000,
78
+ /** The longest a long poll is held before it answers empty. */
79
+ pollMaxWaitSeconds: 25,
80
+ /** How long a claim holds a command before the lease may be reclaimed. */
81
+ leaseMs: 120_000,
82
+ } as const;
83
+
84
+ /** `now - lastSeenAt` past this and the machine is no longer connected. */
85
+ export const MACHINE_PRESENCE_TTL_MS = MACHINE_LIMITS_V1.presenceTtlMs;
86
+ /** Commands one machine may hold queued at once. */
87
+ export const MACHINE_MAX_QUEUE = MACHINE_LIMITS_V1.maxQueue;
88
+ /** Machines one User may hold registered at once. */
89
+ export const MACHINE_MAX_PER_USER = MACHINE_LIMITS_V1.maxMachinesPerUser;
90
+ /** Commands one User may dispatch in a day. */
91
+ export const MACHINE_COMMANDS_PER_DAY = MACHINE_LIMITS_V1.commandsPerDay;
92
+
93
+ export type MachineErrorCodeV1 = "invalid-request" | "limit-exceeded";
94
+
95
+ export class MachineDecodeError extends Error {
96
+ // Plain fields rather than parameter properties: this module is also loaded
97
+ // by the desktop shell's type-stripping runtime, which erases types and
98
+ // transforms nothing.
99
+ readonly code: MachineErrorCodeV1;
100
+
101
+ constructor(message: string, code: MachineErrorCodeV1 = "invalid-request") {
102
+ super(message);
103
+ this.name = "MachineDecodeError";
104
+ this.code = code;
105
+ }
106
+ }
107
+
108
+ function fail(message: string): never {
109
+ throw new MachineDecodeError(message);
110
+ }
111
+
112
+ function object(input: unknown, label: string): Record<string, unknown> {
113
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
114
+ fail(`${label} must be an object`);
115
+ }
116
+ return input as Record<string, unknown>;
117
+ }
118
+
119
+ /** Refuses a field the schema does not declare, so a caller cannot smuggle one. */
120
+ function exactly(
121
+ input: Record<string, unknown>,
122
+ allowed: readonly string[],
123
+ label: string,
124
+ ): void {
125
+ for (const key of Object.keys(input)) {
126
+ if (!allowed.includes(key)) fail(`${label} has an unknown field: ${key}`);
127
+ }
128
+ }
129
+
130
+ function boundedString(
131
+ input: unknown,
132
+ maximumLength: number,
133
+ label: string,
134
+ ): string {
135
+ if (typeof input !== "string" || input.length === 0) {
136
+ fail(`${label} must be a non-empty string`);
137
+ }
138
+ const value = input as string;
139
+ if (value.length > maximumLength) {
140
+ throw new MachineDecodeError(
141
+ `${label} exceeds ${maximumLength} characters`,
142
+ "limit-exceeded",
143
+ );
144
+ }
145
+ return value;
146
+ }
147
+
148
+ function boundedInteger(
149
+ input: unknown,
150
+ minimum: number,
151
+ maximum: number,
152
+ label: string,
153
+ ): number {
154
+ if (!Number.isSafeInteger(input)) fail(`${label} must be an integer`);
155
+ const value = input as number;
156
+ if (value < minimum || value > maximum) {
157
+ throw new MachineDecodeError(
158
+ `${label} must be between ${minimum} and ${maximum}`,
159
+ "limit-exceeded",
160
+ );
161
+ }
162
+ return value;
163
+ }
164
+
165
+ function boolean(input: unknown, label: string): boolean {
166
+ if (typeof input !== "boolean") fail(`${label} must be a boolean`);
167
+ return input;
168
+ }
169
+
170
+ /**
171
+ * A machine identifier is an opaque id, never the hostname: §2.16 shows the
172
+ * split (`994dc2ee-…` with `Tims-M5-MacBook-Pro.local` as the label), the
173
+ * label is user-editable, and the id is a storage key, a path segment, and the
174
+ * tail of `plugin-audit`'s `machine:<id>` target — whose own identifier rule
175
+ * (`plugin-audit/src/classify.ts`) this pattern matches, so every id minted
176
+ * here can be audited.
177
+ */
178
+ const MACHINE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
179
+
180
+ /**
181
+ * Every other identifier the protocol carries. Colons are legal because a
182
+ * `commandId` *is* the Bot Durable Object's `effectId`
183
+ * (`tool:<turn>:<step>:<ordinal>`) — that identity is what makes a retried
184
+ * dispatch idempotent.
185
+ */
186
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:@-]*$/;
187
+
188
+ export function decodeMachineIdV1(input: unknown, label = "machineId"): string {
189
+ const value = boundedString(input, MACHINE_LIMITS_V1.identifier, label);
190
+ if (!MACHINE_ID.test(value)) fail(`${label} is not a valid machine id`);
191
+ return value;
192
+ }
193
+
194
+ function identifier(input: unknown, label: string): string {
195
+ const value = boundedString(input, MACHINE_LIMITS_V1.identifier, label);
196
+ if (!IDENTIFIER.test(value)) fail(`${label} is not a valid identifier`);
197
+ return value;
198
+ }
199
+
200
+ function timestamp(input: unknown, label: string): string {
201
+ const value = boundedString(input, 64, label);
202
+ if (Number.isNaN(Date.parse(value))) fail(`${label} must be a timestamp`);
203
+ return value;
204
+ }
205
+
206
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
207
+
208
+ /**
209
+ * A path on the machine. Deliberately looser than `decodeComputerPathV1`: the
210
+ * machine is somebody's laptop, where `~/Documents` and a Windows drive letter
211
+ * are both ordinary, and the backend has no filesystem to normalize against.
212
+ * What is refused is what a path can never legitimately carry — emptiness and
213
+ * control characters, which is how a path smuggles a second argument.
214
+ */
215
+ export function decodeMachinePathV1(input: unknown, label = "path"): string {
216
+ const value = boundedString(input, MACHINE_LIMITS_V1.path, label);
217
+ if (CONTROL_CHARACTERS.test(value)) {
218
+ fail(`${label} must not contain control characters`);
219
+ }
220
+ return value;
221
+ }
222
+
223
+ const BASE64 =
224
+ /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
225
+
226
+ function base64Field(input: unknown, label: string): string {
227
+ if (typeof input !== "string") fail(`${label} must be a base64 string`);
228
+ const value = input as string;
229
+ if (value.length > MACHINE_LIMITS_V1.payloadBase64) {
230
+ throw new MachineDecodeError(
231
+ `${label} exceeds ${MACHINE_LIMITS_V1.payloadBase64} encoded bytes`,
232
+ "limit-exceeded",
233
+ );
234
+ }
235
+ if (!BASE64.test(value)) fail(`${label} is not valid base64`);
236
+ return value;
237
+ }
238
+
239
+ function literal<T extends string>(
240
+ input: unknown,
241
+ allowed: readonly T[],
242
+ label: string,
243
+ ): T {
244
+ if (typeof input !== "string" || !allowed.includes(input as T)) {
245
+ fail(`${label} must be one of: ${allowed.join(", ")}`);
246
+ }
247
+ return input as T;
248
+ }
249
+
250
+ function schemaVersion(input: Record<string, unknown>, label: string): 1 {
251
+ if (input.schemaVersion !== 1) {
252
+ fail(`${label} schemaVersion is unsupported`);
253
+ }
254
+ return 1;
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // Vocabulary
259
+ // ---------------------------------------------------------------------------
260
+
261
+ export type MachinePlatformV1 = "macos" | "windows" | "linux";
262
+
263
+ export const MACHINE_PLATFORMS_V1: readonly MachinePlatformV1[] = [
264
+ "macos",
265
+ "windows",
266
+ "linux",
267
+ ];
268
+
269
+ /**
270
+ * What an agent says it can do. The backend never assumes: a tool that needs
271
+ * `exec` refuses visibly against a machine that did not report it, and only a
272
+ * `macos` agent may report `messages`.
273
+ */
274
+ export type MachineCapabilityV1 = "exec" | "files" | "messages";
275
+
276
+ export const MACHINE_CAPABILITIES_V1: readonly MachineCapabilityV1[] = [
277
+ "exec",
278
+ "files",
279
+ "messages",
280
+ ];
281
+
282
+ /**
283
+ * Where one command stands.
284
+ *
285
+ * `unknown` is load-bearing rather than a fallback, exactly as it is in
286
+ * `plugin-audit`: a lease that expires returns its command to `queued` once,
287
+ * and if the second lease also lapses the command ends `unknown` — the backend
288
+ * does not know whether it ran on the machine, and inventing an answer is what
289
+ * the reconciliation rule forbids.
290
+ */
291
+ export type MachineCommandStatusV1 =
292
+ "queued" | "claimed" | "done" | "expired" | "unknown";
293
+
294
+ export const MACHINE_COMMAND_STATUSES_V1: readonly MachineCommandStatusV1[] = [
295
+ "queued",
296
+ "claimed",
297
+ "done",
298
+ "expired",
299
+ "unknown",
300
+ ];
301
+
302
+ export type MachineCommandOutcomeV1 = "ok" | "error" | "refused" | "timeout";
303
+
304
+ export const MACHINE_COMMAND_OUTCOMES_V1: readonly MachineCommandOutcomeV1[] = [
305
+ "ok",
306
+ "error",
307
+ "refused",
308
+ "timeout",
309
+ ];
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // Messages.app (register row 57g)
313
+ // ---------------------------------------------------------------------------
314
+
315
+ /**
316
+ * What a Messages call may carry.
317
+ *
318
+ * Separate from `MACHINE_LIMITS_V1` because these are the *content* bounds of
319
+ * one capability rather than the transport's: a search term, a chat id, the
320
+ * body of a message somebody is about to send. They are declared here, in the
321
+ * protocol, for the same reason every other bound is — the tool, the queue and
322
+ * the agent that reads `chat.db` must all refuse at the same size.
323
+ */
324
+ export const MACHINE_MESSAGES_LIMITS_V1 = {
325
+ /** A search term, or a chat filter. */
326
+ query: 512,
327
+ /** A chat's guid or its `chat_identifier`. */
328
+ chatId: 256,
329
+ /** A handle a message is addressed to — a phone number, an Apple ID, a guid. */
330
+ recipient: 256,
331
+ /** The body of one outbound message. */
332
+ text: 4_096,
333
+ /** An attachment's row id or guid, as a chat item reports it. */
334
+ attachmentId: 256,
335
+ /** The most rows one read may ask for, and the default it takes without one. */
336
+ rows: 200,
337
+ defaultRows: 50,
338
+ /** The most one attachment may return. */
339
+ attachmentBytes: 8 * 1_024 * 1_024,
340
+ /** Free text on a permission report, e.g. the macOS error that named it. */
341
+ detail: 512,
342
+ } as const;
343
+
344
+ export interface MachineMessagesCheckPermissionsCallV1 {
345
+ kind: "check-permissions";
346
+ }
347
+
348
+ export interface MachineMessagesFindChatsCallV1 {
349
+ kind: "find-chats";
350
+ query?: string;
351
+ limit: number;
352
+ }
353
+
354
+ export interface MachineMessagesChatItemsCallV1 {
355
+ kind: "chat-items";
356
+ chatId: string;
357
+ limit: number;
358
+ /** Page backwards: only items older than this `message.ROWID`. */
359
+ beforeRowId?: number;
360
+ }
361
+
362
+ export interface MachineMessagesSearchCallV1 {
363
+ kind: "search";
364
+ query: string;
365
+ limit: number;
366
+ }
367
+
368
+ export interface MachineMessagesActivityCallV1 {
369
+ kind: "activity";
370
+ limit: number;
371
+ }
372
+
373
+ export interface MachineMessagesFetchAttachmentCallV1 {
374
+ kind: "fetch-attachment";
375
+ attachmentId: string;
376
+ maxBytes: number;
377
+ }
378
+
379
+ export interface MachineMessagesSendCallV1 {
380
+ kind: "send";
381
+ to: string;
382
+ text: string;
383
+ }
384
+
385
+ /**
386
+ * The seven Messages calls of §4.2, one per GrokBot tool:
387
+ * `CheckIMessagePermissions`, `FindIMessageChats`, `ChatItems`,
388
+ * `SearchIMessages`, `IMessageActivity`, `FetchIMessageAttachment`,
389
+ * `SendIMessage`.
390
+ *
391
+ * They ride the *same* command queue as `exec` and `read`. That is what keeps
392
+ * the register's "per-platform Package" from meaning "second protocol": the
393
+ * Messages Package builds one of these, wraps it in `{kind:"messages"}` and
394
+ * hands it to the transport that already exists.
395
+ */
396
+ export type MachineMessagesCallV1 =
397
+ | MachineMessagesCheckPermissionsCallV1
398
+ | MachineMessagesFindChatsCallV1
399
+ | MachineMessagesChatItemsCallV1
400
+ | MachineMessagesSearchCallV1
401
+ | MachineMessagesActivityCallV1
402
+ | MachineMessagesFetchAttachmentCallV1
403
+ | MachineMessagesSendCallV1;
404
+
405
+ export type MachineMessagesCallKindV1 = MachineMessagesCallV1["kind"];
406
+
407
+ export const MACHINE_MESSAGES_CALL_KINDS_V1: readonly MachineMessagesCallKindV1[] =
408
+ [
409
+ "check-permissions",
410
+ "find-chats",
411
+ "chat-items",
412
+ "search",
413
+ "activity",
414
+ "fetch-attachment",
415
+ "send",
416
+ ];
417
+
418
+ /**
419
+ * Whether a call reads or acts.
420
+ *
421
+ * The whole of the plan's open decision 4 turns on this one predicate: the six
422
+ * reads are exempt from a per-call approval card, and `send` — an outbound
423
+ * external message — always takes one.
424
+ */
425
+ export function machineMessagesCallIsReadV1(
426
+ call: MachineMessagesCallV1,
427
+ ): boolean {
428
+ return call.kind !== "send";
429
+ }
430
+
431
+ export function decodeMachineMessagesCallV1(
432
+ input: unknown,
433
+ label = "messages call",
434
+ ): MachineMessagesCallV1 {
435
+ const value = object(input, label);
436
+ const kind = literal(
437
+ value.kind,
438
+ MACHINE_MESSAGES_CALL_KINDS_V1,
439
+ `${label} kind`,
440
+ );
441
+ const rows = (candidate: unknown): number =>
442
+ boundedInteger(
443
+ candidate,
444
+ 1,
445
+ MACHINE_MESSAGES_LIMITS_V1.rows,
446
+ `${label} limit`,
447
+ );
448
+ if (kind === "check-permissions") {
449
+ exactly(value, ["kind"], `${label} check-permissions`);
450
+ return { kind };
451
+ }
452
+ if (kind === "find-chats") {
453
+ exactly(value, ["kind", "query", "limit"], `${label} find-chats`);
454
+ return {
455
+ kind,
456
+ ...(value.query === undefined
457
+ ? {}
458
+ : {
459
+ query: boundedString(
460
+ value.query,
461
+ MACHINE_MESSAGES_LIMITS_V1.query,
462
+ `${label} query`,
463
+ ),
464
+ }),
465
+ limit: rows(value.limit),
466
+ };
467
+ }
468
+ if (kind === "chat-items") {
469
+ exactly(
470
+ value,
471
+ ["kind", "chatId", "limit", "beforeRowId"],
472
+ `${label} chat-items`,
473
+ );
474
+ return {
475
+ kind,
476
+ chatId: boundedString(
477
+ value.chatId,
478
+ MACHINE_MESSAGES_LIMITS_V1.chatId,
479
+ `${label} chatId`,
480
+ ),
481
+ limit: rows(value.limit),
482
+ ...(value.beforeRowId === undefined
483
+ ? {}
484
+ : {
485
+ beforeRowId: boundedInteger(
486
+ value.beforeRowId,
487
+ 1,
488
+ Number.MAX_SAFE_INTEGER,
489
+ `${label} beforeRowId`,
490
+ ),
491
+ }),
492
+ };
493
+ }
494
+ if (kind === "search") {
495
+ exactly(value, ["kind", "query", "limit"], `${label} search`);
496
+ return {
497
+ kind,
498
+ query: boundedString(
499
+ value.query,
500
+ MACHINE_MESSAGES_LIMITS_V1.query,
501
+ `${label} query`,
502
+ ),
503
+ limit: rows(value.limit),
504
+ };
505
+ }
506
+ if (kind === "activity") {
507
+ exactly(value, ["kind", "limit"], `${label} activity`);
508
+ return { kind, limit: rows(value.limit) };
509
+ }
510
+ if (kind === "fetch-attachment") {
511
+ exactly(
512
+ value,
513
+ ["kind", "attachmentId", "maxBytes"],
514
+ `${label} fetch-attachment`,
515
+ );
516
+ return {
517
+ kind,
518
+ attachmentId: boundedString(
519
+ value.attachmentId,
520
+ MACHINE_MESSAGES_LIMITS_V1.attachmentId,
521
+ `${label} attachmentId`,
522
+ ),
523
+ maxBytes: boundedInteger(
524
+ value.maxBytes,
525
+ 1,
526
+ MACHINE_MESSAGES_LIMITS_V1.attachmentBytes,
527
+ `${label} maxBytes`,
528
+ ),
529
+ };
530
+ }
531
+ exactly(value, ["kind", "to", "text"], `${label} send`);
532
+ return {
533
+ kind,
534
+ to: boundedString(
535
+ value.to,
536
+ MACHINE_MESSAGES_LIMITS_V1.recipient,
537
+ `${label} to`,
538
+ ),
539
+ text: boundedString(
540
+ value.text,
541
+ MACHINE_MESSAGES_LIMITS_V1.text,
542
+ `${label} text`,
543
+ ),
544
+ };
545
+ }
546
+
547
+ /**
548
+ * What macOS has granted the agent, as the agent reports it.
549
+ *
550
+ * Row 57g's third gate. Neither flag can be *granted* from here — TCC consent
551
+ * is the User's, given in System Settings — so the protocol carries only what
552
+ * was observed and when: reading `~/Library/Messages/chat.db` needs Full Disk
553
+ * Access, and telling Messages.app to send needs Automation rights.
554
+ */
555
+ export interface MachineMessagesPermissionsV1 {
556
+ schemaVersion: 1;
557
+ fullDiskAccess: boolean;
558
+ automation: boolean;
559
+ checkedAt: string;
560
+ /** Whatever macOS said, when it said anything. Never a path to a secret. */
561
+ detail?: string;
562
+ }
563
+
564
+ export function decodeMachineMessagesPermissionsV1(
565
+ input: unknown,
566
+ label = "messages permissions",
567
+ ): MachineMessagesPermissionsV1 {
568
+ const value = object(input, label);
569
+ exactly(
570
+ value,
571
+ ["schemaVersion", "fullDiskAccess", "automation", "checkedAt", "detail"],
572
+ label,
573
+ );
574
+ return {
575
+ schemaVersion: schemaVersion(value, label),
576
+ fullDiskAccess: boolean(value.fullDiskAccess, `${label} fullDiskAccess`),
577
+ automation: boolean(value.automation, `${label} automation`),
578
+ checkedAt: timestamp(value.checkedAt, `${label} checkedAt`),
579
+ ...(value.detail === undefined
580
+ ? {}
581
+ : {
582
+ detail: boundedString(
583
+ value.detail,
584
+ MACHINE_MESSAGES_LIMITS_V1.detail,
585
+ `${label} detail`,
586
+ ),
587
+ }),
588
+ };
589
+ }
590
+
591
+ /**
592
+ * Whether the last report clears a call to run at all.
593
+ *
594
+ * A report that was never taken is not a grant: an unknown permission refuses
595
+ * exactly as a denied one does, and the remediation is the same sentence —
596
+ * run the permission check.
597
+ */
598
+ export function machineMessagesPermittedV1(
599
+ call: MachineMessagesCallV1,
600
+ permissions: MachineMessagesPermissionsV1 | undefined,
601
+ ): boolean {
602
+ if (call.kind === "check-permissions") return true;
603
+ if (!permissions) return false;
604
+ return call.kind === "send"
605
+ ? permissions.fullDiskAccess && permissions.automation
606
+ : permissions.fullDiskAccess;
607
+ }
608
+
609
+ // ---------------------------------------------------------------------------
610
+ // Operations
611
+ // ---------------------------------------------------------------------------
612
+
613
+ export interface MachineExecOpV1 {
614
+ kind: "exec";
615
+ command: string;
616
+ cwd?: string;
617
+ timeoutMs: number;
618
+ maxOutputBytes: number;
619
+ }
620
+
621
+ export interface MachineReadOpV1 {
622
+ kind: "read";
623
+ path: string;
624
+ maxBytes: number;
625
+ }
626
+
627
+ /** `CopyToBox`: the machine reads `path` and the bytes land in the Workspace. */
628
+ export interface MachineCopyToComputerOpV1 {
629
+ kind: "copy-to-computer";
630
+ path: string;
631
+ workspacePath: string;
632
+ }
633
+
634
+ /** `CopyFromBox`: bytes from the Workspace are written to `path`. */
635
+ export interface MachineCopyFromComputerOpV1 {
636
+ kind: "copy-from-computer";
637
+ path: string;
638
+ workspacePath: string;
639
+ }
640
+
641
+ /**
642
+ * One Messages.app call, addressed to the registered Mac (register row 57g).
643
+ *
644
+ * It is an op like any other, which is the point: the Messages Package builds
645
+ * one and hands it to this queue, so there is no second transport, no second
646
+ * claim, and no second idempotency story.
647
+ */
648
+ export interface MachineMessagesOpV1 {
649
+ kind: "messages";
650
+ call: MachineMessagesCallV1;
651
+ }
652
+
653
+ /**
654
+ * What one command asks the machine to do.
655
+ *
656
+ * The `{kind:"messages"}` variant is row 57g's, added additively: widening this
657
+ * union does not move the protocol version, which is the whole reason the
658
+ * Messages Package needs no transport of its own.
659
+ */
660
+ export type MachineOpV1 =
661
+ | MachineExecOpV1
662
+ | MachineReadOpV1
663
+ | MachineCopyToComputerOpV1
664
+ | MachineCopyFromComputerOpV1
665
+ | MachineMessagesOpV1;
666
+
667
+ export type MachineOpKindV1 = MachineOpV1["kind"];
668
+
669
+ export const MACHINE_OP_KINDS_V1: readonly MachineOpKindV1[] = [
670
+ "exec",
671
+ "read",
672
+ "copy-to-computer",
673
+ "copy-from-computer",
674
+ "messages",
675
+ ];
676
+
677
+ /**
678
+ * The capability an op requires of the machine that will run it.
679
+ *
680
+ * `messages` is its own capability rather than a flavour of `files`, and the
681
+ * enrollment decoder refuses it from anything but a macOS agent: that is row
682
+ * 57g's second gate, held in the one place every runtime already decodes.
683
+ */
684
+ export function machineOpCapabilityV1(op: MachineOpV1): MachineCapabilityV1 {
685
+ if (op.kind === "exec") return "exec";
686
+ if (op.kind === "messages") return "messages";
687
+ return "files";
688
+ }
689
+
690
+ export function decodeMachineOpV1(
691
+ input: unknown,
692
+ label = "machine op",
693
+ ): MachineOpV1 {
694
+ const value = object(input, label);
695
+ const kind = literal(value.kind, MACHINE_OP_KINDS_V1, `${label} kind`);
696
+ if (kind === "exec") {
697
+ exactly(
698
+ value,
699
+ ["kind", "command", "cwd", "timeoutMs", "maxOutputBytes"],
700
+ `${label} exec`,
701
+ );
702
+ return {
703
+ kind,
704
+ command: boundedString(
705
+ value.command,
706
+ MACHINE_LIMITS_V1.command,
707
+ `${label} command`,
708
+ ),
709
+ ...(value.cwd === undefined
710
+ ? {}
711
+ : {
712
+ cwd: boundedString(
713
+ value.cwd,
714
+ MACHINE_LIMITS_V1.cwd,
715
+ `${label} cwd`,
716
+ ),
717
+ }),
718
+ timeoutMs: boundedInteger(
719
+ value.timeoutMs,
720
+ 1,
721
+ MACHINE_LIMITS_V1.execTimeoutMs,
722
+ `${label} timeoutMs`,
723
+ ),
724
+ maxOutputBytes: boundedInteger(
725
+ value.maxOutputBytes,
726
+ 1,
727
+ MACHINE_LIMITS_V1.outputBytes,
728
+ `${label} maxOutputBytes`,
729
+ ),
730
+ };
731
+ }
732
+ if (kind === "messages") {
733
+ exactly(value, ["kind", "call"], `${label} messages`);
734
+ return {
735
+ kind,
736
+ call: decodeMachineMessagesCallV1(value.call, `${label} call`),
737
+ };
738
+ }
739
+ if (kind === "read") {
740
+ exactly(value, ["kind", "path", "maxBytes"], `${label} read`);
741
+ return {
742
+ kind,
743
+ path: decodeMachinePathV1(value.path, `${label} path`),
744
+ maxBytes: boundedInteger(
745
+ value.maxBytes,
746
+ 1,
747
+ MACHINE_LIMITS_V1.readBytes,
748
+ `${label} maxBytes`,
749
+ ),
750
+ };
751
+ }
752
+ exactly(value, ["kind", "path", "workspacePath"], `${label} copy`);
753
+ return {
754
+ kind,
755
+ path: decodeMachinePathV1(value.path, `${label} path`),
756
+ workspacePath: boundedString(
757
+ value.workspacePath,
758
+ MACHINE_LIMITS_V1.workspacePath,
759
+ `${label} workspacePath`,
760
+ ),
761
+ };
762
+ }
763
+
764
+ // ---------------------------------------------------------------------------
765
+ // Pairing and enrollment
766
+ // ---------------------------------------------------------------------------
767
+
768
+ /** What the browser asks for. The label is the machine's if it omits one. */
769
+ export interface MachinePairingRequestV1 {
770
+ label?: string;
771
+ }
772
+
773
+ export function decodeMachinePairingRequestV1(
774
+ input: unknown,
775
+ label = "machine pairing request",
776
+ ): MachinePairingRequestV1 {
777
+ const value = object(input, label);
778
+ exactly(value, ["label"], label);
779
+ return value.label === undefined
780
+ ? {}
781
+ : {
782
+ label: boundedString(
783
+ value.label,
784
+ MACHINE_LIMITS_V1.label,
785
+ `${label} label`,
786
+ ),
787
+ };
788
+ }
789
+
790
+ /**
791
+ * The one-time offer the browser shows and the machine presents.
792
+ *
793
+ * The code is the only secret a browser ever holds for a machine, and it is
794
+ * spent on first use and dead in five minutes; the long-lived machine token is
795
+ * minted on the far side of enrollment and never reaches a browser bundle.
796
+ */
797
+ export interface MachinePairingOfferV1 {
798
+ schemaVersion: 1;
799
+ code: string;
800
+ machineId: string;
801
+ expiresAt: string;
802
+ }
803
+
804
+ export function decodeMachinePairingOfferV1(
805
+ input: unknown,
806
+ label = "machine pairing offer",
807
+ ): MachinePairingOfferV1 {
808
+ const value = object(input, label);
809
+ exactly(value, ["schemaVersion", "code", "machineId", "expiresAt"], label);
810
+ return {
811
+ schemaVersion: schemaVersion(value, label),
812
+ code: boundedString(
813
+ value.code,
814
+ MACHINE_LIMITS_V1.pairingCode,
815
+ `${label} code`,
816
+ ),
817
+ machineId: decodeMachineIdV1(value.machineId, `${label} machineId`),
818
+ expiresAt: timestamp(value.expiresAt, `${label} expiresAt`),
819
+ };
820
+ }
821
+
822
+ /** What the machine presents to enroll, bearing the pairing code. */
823
+ export interface MachineEnrollmentV1 {
824
+ schemaVersion: 1;
825
+ code: string;
826
+ label: string;
827
+ platform: MachinePlatformV1;
828
+ agentVersion: string;
829
+ capabilities: MachineCapabilityV1[];
830
+ }
831
+
832
+ function capabilities(
833
+ input: unknown,
834
+ platform: MachinePlatformV1,
835
+ label: string,
836
+ ): MachineCapabilityV1[] {
837
+ if (!Array.isArray(input)) fail(`${label} must be an array`);
838
+ if (input.length > MACHINE_LIMITS_V1.capabilities) {
839
+ throw new MachineDecodeError(
840
+ `${label} exceeds ${MACHINE_LIMITS_V1.capabilities} entries`,
841
+ "limit-exceeded",
842
+ );
843
+ }
844
+ const decoded: MachineCapabilityV1[] = [];
845
+ for (const entry of input) {
846
+ const capability = literal(
847
+ entry,
848
+ MACHINE_CAPABILITIES_V1,
849
+ `${label} entry`,
850
+ );
851
+ if (decoded.includes(capability)) fail(`${label} repeats ${capability}`);
852
+ // Row 57g's second gate: Messages.app is a macOS fact, so no other
853
+ // platform's agent may claim it however loudly it asks.
854
+ if (capability === "messages" && platform !== "macos") {
855
+ fail(`${label} may only report messages on macos`);
856
+ }
857
+ decoded.push(capability);
858
+ }
859
+ return decoded;
860
+ }
861
+
862
+ export function decodeMachineEnrollmentV1(
863
+ input: unknown,
864
+ label = "machine enrollment",
865
+ ): MachineEnrollmentV1 {
866
+ const value = object(input, label);
867
+ exactly(
868
+ value,
869
+ [
870
+ "schemaVersion",
871
+ "code",
872
+ "label",
873
+ "platform",
874
+ "agentVersion",
875
+ "capabilities",
876
+ ],
877
+ label,
878
+ );
879
+ const platform = literal(
880
+ value.platform,
881
+ MACHINE_PLATFORMS_V1,
882
+ `${label} platform`,
883
+ );
884
+ return {
885
+ schemaVersion: schemaVersion(value, label),
886
+ code: boundedString(
887
+ value.code,
888
+ MACHINE_LIMITS_V1.pairingCode,
889
+ `${label} code`,
890
+ ),
891
+ label: boundedString(
892
+ value.label,
893
+ MACHINE_LIMITS_V1.label,
894
+ `${label} label`,
895
+ ),
896
+ platform,
897
+ agentVersion: boundedString(
898
+ value.agentVersion,
899
+ MACHINE_LIMITS_V1.agentVersion,
900
+ `${label} agentVersion`,
901
+ ),
902
+ capabilities: capabilities(
903
+ value.capabilities,
904
+ platform,
905
+ `${label} capabilities`,
906
+ ),
907
+ };
908
+ }
909
+
910
+ /**
911
+ * The one moment a machine token exists outside the machine. The backend keeps
912
+ * only `SHA-256(token)`; this response is the sole delivery, and a machine
913
+ * that loses it pairs again rather than asking for it back.
914
+ */
915
+ export interface MachineEnrollmentReceiptV1 {
916
+ schemaVersion: 1;
917
+ machineId: string;
918
+ token: string;
919
+ keyVersion: number;
920
+ }
921
+
922
+ export function decodeMachineEnrollmentReceiptV1(
923
+ input: unknown,
924
+ label = "machine enrollment receipt",
925
+ ): MachineEnrollmentReceiptV1 {
926
+ const value = object(input, label);
927
+ exactly(value, ["schemaVersion", "machineId", "token", "keyVersion"], label);
928
+ return {
929
+ schemaVersion: schemaVersion(value, label),
930
+ machineId: decodeMachineIdV1(value.machineId, `${label} machineId`),
931
+ token: boundedString(value.token, 2_048, `${label} token`),
932
+ keyVersion: boundedInteger(
933
+ value.keyVersion,
934
+ 1,
935
+ 1_000_000,
936
+ `${label} keyVersion`,
937
+ ),
938
+ };
939
+ }
940
+
941
+ // ---------------------------------------------------------------------------
942
+ // The durable machine record
943
+ // ---------------------------------------------------------------------------
944
+
945
+ /**
946
+ * One registered machine, as the User Durable Object holds it.
947
+ *
948
+ * `tokenDigest` and not the token: "no secrets client-side" has a mirror on
949
+ * the server, which is that durable state holds what *proves* a secret and
950
+ * never the secret. `connected` is absent on purpose — it is arithmetic over
951
+ * `lastSeenAt` (see `machineConnectedV1`), so a machine that stops polling
952
+ * goes offline by itself with nothing to clean up after an eviction.
953
+ */
954
+ export interface MachineRecordV1 {
955
+ schemaVersion: 1;
956
+ machineId: string;
957
+ userId: string;
958
+ label: string;
959
+ platform: MachinePlatformV1;
960
+ agentVersion: string;
961
+ capabilities: MachineCapabilityV1[];
962
+ registeredAt: string;
963
+ lastSeenAt: string;
964
+ keyVersion: number;
965
+ tokenDigest: string;
966
+ revokedAt?: string;
967
+ /**
968
+ * The last Messages permission report this machine sent (row 57g's third
969
+ * gate). Absent until the permission check has been run once, and absent is
970
+ * a refusal rather than a grant.
971
+ */
972
+ messagesPermissions?: MachineMessagesPermissionsV1;
973
+ }
974
+
975
+ const DIGEST = /^[0-9a-f]{64}$/;
976
+
977
+ export function decodeMachineRecordV1(
978
+ input: unknown,
979
+ label = "machine record",
980
+ ): MachineRecordV1 {
981
+ const value = object(input, label);
982
+ exactly(
983
+ value,
984
+ [
985
+ "schemaVersion",
986
+ "machineId",
987
+ "userId",
988
+ "label",
989
+ "platform",
990
+ "agentVersion",
991
+ "capabilities",
992
+ "registeredAt",
993
+ "lastSeenAt",
994
+ "keyVersion",
995
+ "tokenDigest",
996
+ "revokedAt",
997
+ "messagesPermissions",
998
+ ],
999
+ label,
1000
+ );
1001
+ const platform = literal(
1002
+ value.platform,
1003
+ MACHINE_PLATFORMS_V1,
1004
+ `${label} platform`,
1005
+ );
1006
+ if (
1007
+ typeof value.tokenDigest !== "string" ||
1008
+ !DIGEST.test(value.tokenDigest)
1009
+ ) {
1010
+ fail(`${label} tokenDigest is invalid`);
1011
+ }
1012
+ return {
1013
+ schemaVersion: schemaVersion(value, label),
1014
+ machineId: decodeMachineIdV1(value.machineId, `${label} machineId`),
1015
+ userId: identifier(value.userId, `${label} userId`),
1016
+ label: boundedString(
1017
+ value.label,
1018
+ MACHINE_LIMITS_V1.label,
1019
+ `${label} label`,
1020
+ ),
1021
+ platform,
1022
+ agentVersion: boundedString(
1023
+ value.agentVersion,
1024
+ MACHINE_LIMITS_V1.agentVersion,
1025
+ `${label} agentVersion`,
1026
+ ),
1027
+ capabilities: capabilities(
1028
+ value.capabilities,
1029
+ platform,
1030
+ `${label} capabilities`,
1031
+ ),
1032
+ registeredAt: timestamp(value.registeredAt, `${label} registeredAt`),
1033
+ lastSeenAt: timestamp(value.lastSeenAt, `${label} lastSeenAt`),
1034
+ keyVersion: boundedInteger(
1035
+ value.keyVersion,
1036
+ 1,
1037
+ 1_000_000,
1038
+ `${label} keyVersion`,
1039
+ ),
1040
+ tokenDigest: value.tokenDigest,
1041
+ ...(value.revokedAt === undefined
1042
+ ? {}
1043
+ : { revokedAt: timestamp(value.revokedAt, `${label} revokedAt`) }),
1044
+ ...(value.messagesPermissions === undefined
1045
+ ? {}
1046
+ : {
1047
+ messagesPermissions: decodeMachineMessagesPermissionsV1(
1048
+ value.messagesPermissions,
1049
+ `${label} messagesPermissions`,
1050
+ ),
1051
+ }),
1052
+ };
1053
+ }
1054
+
1055
+ /**
1056
+ * `connected`, derived and never stored.
1057
+ *
1058
+ * A stored flag would need a writer on every disconnection, and the one event
1059
+ * that matters — a laptop that closes its lid — sends nothing. Presence is
1060
+ * therefore the absence of a revocation and the freshness of the last poll,
1061
+ * which is still true after an eviction with no recovery step at all.
1062
+ */
1063
+ export function machineConnectedV1(
1064
+ record: Pick<MachineRecordV1, "lastSeenAt" | "revokedAt">,
1065
+ now: number | Date,
1066
+ ttlMs: number = MACHINE_PRESENCE_TTL_MS,
1067
+ ): boolean {
1068
+ if (record.revokedAt !== undefined) return false;
1069
+ const seen = Date.parse(record.lastSeenAt);
1070
+ if (Number.isNaN(seen)) return false;
1071
+ const at = typeof now === "number" ? now : now.getTime();
1072
+ const age = at - seen;
1073
+ // Two clocks are involved, so a `lastSeenAt` slightly ahead of the reader is
1074
+ // ordinary skew and still counts as present — but only within the same TTL,
1075
+ // so a wildly future timestamp cannot read as connected forever.
1076
+ return age >= -ttlMs && age <= ttlMs;
1077
+ }
1078
+
1079
+ // ---------------------------------------------------------------------------
1080
+ // The command queue
1081
+ // ---------------------------------------------------------------------------
1082
+
1083
+ /**
1084
+ * One queued command.
1085
+ *
1086
+ * `commandId` is the Bot Durable Object's `effectId`, which is what makes the
1087
+ * whole path idempotent: a dispatch replayed after an eviction addresses the
1088
+ * same queue key, a second claim answers `already-claimed`, and a result for a
1089
+ * command already terminal answers `replayed` and changes nothing.
1090
+ */
1091
+ export interface MachineCommandV1 {
1092
+ schemaVersion: 1;
1093
+ commandId: string;
1094
+ machineId: string;
1095
+ botId: string;
1096
+ runId: string;
1097
+ turn: number;
1098
+ approvalId: string;
1099
+ op: MachineOpV1;
1100
+ issuedAt: string;
1101
+ status: MachineCommandStatusV1;
1102
+ claimedAt?: string;
1103
+ leaseExpiresAt?: string;
1104
+ }
1105
+
1106
+ export function decodeMachineCommandV1(
1107
+ input: unknown,
1108
+ label = "machine command",
1109
+ ): MachineCommandV1 {
1110
+ const value = object(input, label);
1111
+ exactly(
1112
+ value,
1113
+ [
1114
+ "schemaVersion",
1115
+ "commandId",
1116
+ "machineId",
1117
+ "botId",
1118
+ "runId",
1119
+ "turn",
1120
+ "approvalId",
1121
+ "op",
1122
+ "issuedAt",
1123
+ "status",
1124
+ "claimedAt",
1125
+ "leaseExpiresAt",
1126
+ ],
1127
+ label,
1128
+ );
1129
+ return {
1130
+ schemaVersion: schemaVersion(value, label),
1131
+ commandId: identifier(value.commandId, `${label} commandId`),
1132
+ machineId: decodeMachineIdV1(value.machineId, `${label} machineId`),
1133
+ botId: identifier(value.botId, `${label} botId`),
1134
+ runId: identifier(value.runId, `${label} runId`),
1135
+ turn: boundedInteger(value.turn, 0, 1_000_000, `${label} turn`),
1136
+ approvalId: identifier(value.approvalId, `${label} approvalId`),
1137
+ op: decodeMachineOpV1(value.op, `${label} op`),
1138
+ issuedAt: timestamp(value.issuedAt, `${label} issuedAt`),
1139
+ status: literal(
1140
+ value.status,
1141
+ MACHINE_COMMAND_STATUSES_V1,
1142
+ `${label} status`,
1143
+ ),
1144
+ ...(value.claimedAt === undefined
1145
+ ? {}
1146
+ : { claimedAt: timestamp(value.claimedAt, `${label} claimedAt`) }),
1147
+ ...(value.leaseExpiresAt === undefined
1148
+ ? {}
1149
+ : {
1150
+ leaseExpiresAt: timestamp(
1151
+ value.leaseExpiresAt,
1152
+ `${label} leaseExpiresAt`,
1153
+ ),
1154
+ }),
1155
+ };
1156
+ }
1157
+
1158
+ /**
1159
+ * What a long poll answers with. `serverTime` is carried so the agent can hold
1160
+ * its own backoff against the backend's clock rather than its laptop's, which
1161
+ * may have been asleep.
1162
+ */
1163
+ export interface MachinePollResultV1 {
1164
+ schemaVersion: 1;
1165
+ commands: MachineCommandV1[];
1166
+ serverTime: string;
1167
+ }
1168
+
1169
+ export function decodeMachinePollResultV1(
1170
+ input: unknown,
1171
+ label = "machine poll result",
1172
+ ): MachinePollResultV1 {
1173
+ const value = object(input, label);
1174
+ exactly(value, ["schemaVersion", "commands", "serverTime"], label);
1175
+ if (!Array.isArray(value.commands))
1176
+ fail(`${label} commands must be an array`);
1177
+ if (value.commands.length > MACHINE_LIMITS_V1.maxQueue) {
1178
+ throw new MachineDecodeError(
1179
+ `${label} exceeds ${MACHINE_LIMITS_V1.maxQueue} commands`,
1180
+ "limit-exceeded",
1181
+ );
1182
+ }
1183
+ return {
1184
+ schemaVersion: schemaVersion(value, label),
1185
+ commands: value.commands.map((command, index) =>
1186
+ decodeMachineCommandV1(command, `${label} command ${index}`),
1187
+ ),
1188
+ serverTime: timestamp(value.serverTime, `${label} serverTime`),
1189
+ };
1190
+ }
1191
+
1192
+ /**
1193
+ * The answer to a claim. `already-claimed` is not an error: a duplicate
1194
+ * delivery is expected on a protocol that survives dropped polls, and saying
1195
+ * so plainly is what stops the same command running twice.
1196
+ */
1197
+ export interface MachineClaimReceiptV1 {
1198
+ schemaVersion: 1;
1199
+ status: "claimed" | "already-claimed";
1200
+ commandId: string;
1201
+ leaseExpiresAt: string;
1202
+ }
1203
+
1204
+ export const MACHINE_CLAIM_STATUSES_V1 = [
1205
+ "claimed",
1206
+ "already-claimed",
1207
+ ] as const;
1208
+
1209
+ export function decodeMachineClaimReceiptV1(
1210
+ input: unknown,
1211
+ label = "machine claim receipt",
1212
+ ): MachineClaimReceiptV1 {
1213
+ const value = object(input, label);
1214
+ exactly(
1215
+ value,
1216
+ ["schemaVersion", "status", "commandId", "leaseExpiresAt"],
1217
+ label,
1218
+ );
1219
+ return {
1220
+ schemaVersion: schemaVersion(value, label),
1221
+ status: literal(value.status, MACHINE_CLAIM_STATUSES_V1, `${label} status`),
1222
+ commandId: identifier(value.commandId, `${label} commandId`),
1223
+ leaseExpiresAt: timestamp(value.leaseExpiresAt, `${label} leaseExpiresAt`),
1224
+ };
1225
+ }
1226
+
1227
+ /**
1228
+ * What the machine reports back. `truncated` is required rather than implied:
1229
+ * output cut at a bound is a different fact from output that ended, and the
1230
+ * Bot is told which.
1231
+ */
1232
+ export interface MachineCommandResultV1 {
1233
+ schemaVersion: 1;
1234
+ commandId: string;
1235
+ finishedAt: string;
1236
+ outcome: MachineCommandOutcomeV1;
1237
+ truncated: boolean;
1238
+ exitCode?: number;
1239
+ stdout?: string;
1240
+ stderr?: string;
1241
+ bytesBase64?: string;
1242
+ message?: string;
1243
+ }
1244
+
1245
+ export function decodeMachineCommandResultV1(
1246
+ input: unknown,
1247
+ label = "machine command result",
1248
+ ): MachineCommandResultV1 {
1249
+ const value = object(input, label);
1250
+ exactly(
1251
+ value,
1252
+ [
1253
+ "schemaVersion",
1254
+ "commandId",
1255
+ "finishedAt",
1256
+ "outcome",
1257
+ "truncated",
1258
+ "exitCode",
1259
+ "stdout",
1260
+ "stderr",
1261
+ "bytesBase64",
1262
+ "message",
1263
+ ],
1264
+ label,
1265
+ );
1266
+ const stream = (key: "stdout" | "stderr"): string => {
1267
+ const held = value[key];
1268
+ if (typeof held !== "string") fail(`${label} ${key} must be a string`);
1269
+ if ((held as string).length > MACHINE_LIMITS_V1.outputBytes) {
1270
+ throw new MachineDecodeError(
1271
+ `${label} ${key} exceeds ${MACHINE_LIMITS_V1.outputBytes} bytes`,
1272
+ "limit-exceeded",
1273
+ );
1274
+ }
1275
+ return held as string;
1276
+ };
1277
+ return {
1278
+ schemaVersion: schemaVersion(value, label),
1279
+ commandId: identifier(value.commandId, `${label} commandId`),
1280
+ finishedAt: timestamp(value.finishedAt, `${label} finishedAt`),
1281
+ outcome: literal(
1282
+ value.outcome,
1283
+ MACHINE_COMMAND_OUTCOMES_V1,
1284
+ `${label} outcome`,
1285
+ ),
1286
+ truncated: boolean(value.truncated, `${label} truncated`),
1287
+ ...(value.exitCode === undefined
1288
+ ? {}
1289
+ : {
1290
+ exitCode: boundedInteger(
1291
+ value.exitCode,
1292
+ -256,
1293
+ 256,
1294
+ `${label} exitCode`,
1295
+ ),
1296
+ }),
1297
+ ...(value.stdout === undefined ? {} : { stdout: stream("stdout") }),
1298
+ ...(value.stderr === undefined ? {} : { stderr: stream("stderr") }),
1299
+ ...(value.bytesBase64 === undefined
1300
+ ? {}
1301
+ : {
1302
+ bytesBase64: base64Field(value.bytesBase64, `${label} bytesBase64`),
1303
+ }),
1304
+ ...(value.message === undefined
1305
+ ? {}
1306
+ : {
1307
+ message: boundedString(
1308
+ value.message,
1309
+ MACHINE_LIMITS_V1.message,
1310
+ `${label} message`,
1311
+ ),
1312
+ }),
1313
+ };
1314
+ }
1315
+
1316
+ /** The answer to a posted result. A replay is recorded once and reported. */
1317
+ export interface MachineResultReceiptV1 {
1318
+ schemaVersion: 1;
1319
+ status: "recorded" | "replayed";
1320
+ commandId: string;
1321
+ }
1322
+
1323
+ export const MACHINE_RESULT_STATUSES_V1 = ["recorded", "replayed"] as const;
1324
+
1325
+ export function decodeMachineResultReceiptV1(
1326
+ input: unknown,
1327
+ label = "machine result receipt",
1328
+ ): MachineResultReceiptV1 {
1329
+ const value = object(input, label);
1330
+ exactly(value, ["schemaVersion", "status", "commandId"], label);
1331
+ return {
1332
+ schemaVersion: schemaVersion(value, label),
1333
+ status: literal(
1334
+ value.status,
1335
+ MACHINE_RESULT_STATUSES_V1,
1336
+ `${label} status`,
1337
+ ),
1338
+ commandId: identifier(value.commandId, `${label} commandId`),
1339
+ };
1340
+ }
1341
+
1342
+ // ---------------------------------------------------------------------------
1343
+ // The registry projection
1344
+ // ---------------------------------------------------------------------------
1345
+
1346
+ /**
1347
+ * One row of `ListMachines` (§2.16), and one row of the Computer settings
1348
+ * section. It carries no digest, no key version and no user id: a projection
1349
+ * hands out what the surface renders and nothing that proves anything.
1350
+ */
1351
+ export interface MachineListEntryV1 {
1352
+ machineId: string;
1353
+ label: string;
1354
+ platform: MachinePlatformV1;
1355
+ capabilities: MachineCapabilityV1[];
1356
+ connected: boolean;
1357
+ lastSeenAt: string;
1358
+ registeredAt: string;
1359
+ revokedAt?: string;
1360
+ /** The last Messages permission report, when one has been taken. */
1361
+ messagesPermissions?: MachineMessagesPermissionsV1;
1362
+ }
1363
+
1364
+ export interface MachineListViewV1 {
1365
+ schemaVersion: 1;
1366
+ machines: MachineListEntryV1[];
1367
+ serverTime: string;
1368
+ }
1369
+
1370
+ /** The projection, pure: the same record and clock give the same row. */
1371
+ export function machineListEntryV1(
1372
+ record: MachineRecordV1,
1373
+ now: number | Date,
1374
+ ttlMs: number = MACHINE_PRESENCE_TTL_MS,
1375
+ ): MachineListEntryV1 {
1376
+ return {
1377
+ machineId: record.machineId,
1378
+ label: record.label,
1379
+ platform: record.platform,
1380
+ capabilities: [...record.capabilities],
1381
+ connected: machineConnectedV1(record, now, ttlMs),
1382
+ lastSeenAt: record.lastSeenAt,
1383
+ registeredAt: record.registeredAt,
1384
+ ...(record.revokedAt === undefined ? {} : { revokedAt: record.revokedAt }),
1385
+ ...(record.messagesPermissions === undefined
1386
+ ? {}
1387
+ : { messagesPermissions: record.messagesPermissions }),
1388
+ };
1389
+ }
1390
+
1391
+ export function decodeMachineListEntryV1(
1392
+ input: unknown,
1393
+ label = "machine list entry",
1394
+ ): MachineListEntryV1 {
1395
+ const value = object(input, label);
1396
+ exactly(
1397
+ value,
1398
+ [
1399
+ "machineId",
1400
+ "label",
1401
+ "platform",
1402
+ "capabilities",
1403
+ "connected",
1404
+ "lastSeenAt",
1405
+ "registeredAt",
1406
+ "revokedAt",
1407
+ "messagesPermissions",
1408
+ ],
1409
+ label,
1410
+ );
1411
+ const platform = literal(
1412
+ value.platform,
1413
+ MACHINE_PLATFORMS_V1,
1414
+ `${label} platform`,
1415
+ );
1416
+ return {
1417
+ machineId: decodeMachineIdV1(value.machineId, `${label} machineId`),
1418
+ label: boundedString(
1419
+ value.label,
1420
+ MACHINE_LIMITS_V1.label,
1421
+ `${label} label`,
1422
+ ),
1423
+ platform,
1424
+ capabilities: capabilities(
1425
+ value.capabilities,
1426
+ platform,
1427
+ `${label} capabilities`,
1428
+ ),
1429
+ connected: boolean(value.connected, `${label} connected`),
1430
+ lastSeenAt: timestamp(value.lastSeenAt, `${label} lastSeenAt`),
1431
+ registeredAt: timestamp(value.registeredAt, `${label} registeredAt`),
1432
+ ...(value.revokedAt === undefined
1433
+ ? {}
1434
+ : { revokedAt: timestamp(value.revokedAt, `${label} revokedAt`) }),
1435
+ ...(value.messagesPermissions === undefined
1436
+ ? {}
1437
+ : {
1438
+ messagesPermissions: decodeMachineMessagesPermissionsV1(
1439
+ value.messagesPermissions,
1440
+ `${label} messagesPermissions`,
1441
+ ),
1442
+ }),
1443
+ };
1444
+ }
1445
+
1446
+ export function decodeMachineListViewV1(
1447
+ input: unknown,
1448
+ label = "machine list view",
1449
+ ): MachineListViewV1 {
1450
+ const value = object(input, label);
1451
+ exactly(value, ["schemaVersion", "machines", "serverTime"], label);
1452
+ if (!Array.isArray(value.machines))
1453
+ fail(`${label} machines must be an array`);
1454
+ if (value.machines.length > MACHINE_LIMITS_V1.maxMachinesPerUser) {
1455
+ throw new MachineDecodeError(
1456
+ `${label} exceeds ${MACHINE_LIMITS_V1.maxMachinesPerUser} machines`,
1457
+ "limit-exceeded",
1458
+ );
1459
+ }
1460
+ return {
1461
+ schemaVersion: schemaVersion(value, label),
1462
+ machines: value.machines.map((entry, index) =>
1463
+ decodeMachineListEntryV1(entry, `${label} entry ${index}`),
1464
+ ),
1465
+ serverTime: timestamp(value.serverTime, `${label} serverTime`),
1466
+ };
1467
+ }
1468
+
1469
+ // ---------------------------------------------------------------------------
1470
+ // What a Messages call answers with
1471
+ // ---------------------------------------------------------------------------
1472
+
1473
+ /**
1474
+ * A Messages reply rides the result DTO that already exists: the rows are JSON
1475
+ * in `stdout`, an attachment's bytes are in `bytesBase64`, and a refusal is
1476
+ * the `refused` outcome with its remediation in `message`. Nothing about the
1477
+ * result envelope changes, which is why row 57g moves no version.
1478
+ *
1479
+ * Only one shape is *decoded* rather than rendered — the permission report,
1480
+ * because the backend acts on it. Chats and messages are read out of somebody's
1481
+ * Messages.app and are tool-result content, fenced like every other tool result
1482
+ * and never instructions; decoding them strictly would buy nothing and would
1483
+ * make an unfamiliar row an error instead of a line the Bot can read.
1484
+ */
1485
+ export const MACHINE_MESSAGES_REPLY_KINDS_V1 = [
1486
+ "permissions",
1487
+ "chats",
1488
+ "items",
1489
+ "attachment",
1490
+ "sent",
1491
+ ] as const;
1492
+
1493
+ export type MachineMessagesReplyKindV1 =
1494
+ (typeof MACHINE_MESSAGES_REPLY_KINDS_V1)[number];
1495
+
1496
+ /** The JSON body a Messages result carries in `stdout`. */
1497
+ export interface MachineMessagesReplyEnvelopeV1 {
1498
+ kind: MachineMessagesReplyKindV1;
1499
+ [field: string]: unknown;
1500
+ }
1501
+
1502
+ /**
1503
+ * The permission report a finished command carries, when it was one.
1504
+ *
1505
+ * Pure, and total: anything that is not an `ok` permission check answers
1506
+ * `undefined`, so the caller's rule is one line — a report updates the record,
1507
+ * and everything else leaves it exactly as it was. A machine that answers
1508
+ * nonsense to a permission check has *not* reported permissions, which is a
1509
+ * refusal, because "absent is not a grant".
1510
+ */
1511
+ export function machineMessagesPermissionsFromResultV1(
1512
+ op: MachineOpV1,
1513
+ result: Pick<MachineCommandResultV1, "outcome" | "stdout">,
1514
+ ): MachineMessagesPermissionsV1 | undefined {
1515
+ if (op.kind !== "messages" || op.call.kind !== "check-permissions") {
1516
+ return undefined;
1517
+ }
1518
+ if (result.outcome !== "ok" || typeof result.stdout !== "string") {
1519
+ return undefined;
1520
+ }
1521
+ let parsed: unknown;
1522
+ try {
1523
+ parsed = JSON.parse(result.stdout) as unknown;
1524
+ } catch {
1525
+ return undefined;
1526
+ }
1527
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1528
+ return undefined;
1529
+ }
1530
+ const body = parsed as Record<string, unknown>;
1531
+ if (body.kind !== "permissions") return undefined;
1532
+ try {
1533
+ return decodeMachineMessagesPermissionsV1(
1534
+ body.permissions,
1535
+ "reported messages permissions",
1536
+ );
1537
+ } catch {
1538
+ return undefined;
1539
+ }
1540
+ }