@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.
- package/package.json +22 -6
- package/src/index.ts +4 -0
- package/src/messages.test.ts +200 -0
- package/src/protocol.test.ts +406 -0
- package/src/protocol.ts +1540 -0
- package/src/quota.test.ts +156 -0
- package/src/quota.ts +152 -0
- package/src/routes.test.ts +105 -0
- package/src/routes.ts +161 -0
- package/src/token.test.ts +149 -0
- package/src/token.ts +241 -0
- package/tsconfig.json +14 -0
- package/README.md +0 -3
package/src/protocol.ts
ADDED
|
@@ -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
|
+
}
|