@frockbot/plugin-user-machine 0.0.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/frockbot.json +66 -0
- package/package.json +54 -6
- package/src/agent.test.ts +509 -0
- package/src/agent.ts +733 -0
- package/src/approval.test.ts +323 -0
- package/src/approval.ts +148 -0
- package/src/backend.test.ts +370 -0
- package/src/backend.ts +370 -0
- package/src/client/MachineSection.vue +97 -0
- package/src/client/MachineSurface.vue +308 -0
- package/src/client/index.test.ts +244 -0
- package/src/client/index.ts +180 -0
- package/src/client/state.ts +49 -0
- package/src/delivery.ts +145 -0
- package/src/desktop.test.ts +233 -0
- package/src/desktop.ts +238 -0
- package/src/device-runner.test.ts +326 -0
- package/src/device-runner.ts +205 -0
- package/src/device.test.ts +418 -0
- package/src/device.ts +707 -0
- package/src/env.d.ts +6 -0
- package/src/index.ts +13 -0
- package/src/intent.ts +325 -0
- package/src/manifest.ts +3 -0
- package/src/pairing.test.ts +85 -0
- package/src/pairing.ts +182 -0
- package/src/storage-keys.ts +102 -0
- package/src/store.test.ts +507 -0
- package/src/store.ts +638 -0
- package/src/target.ts +86 -0
- package/src/testing.ts +352 -0
- package/src/user.test.ts +182 -0
- package/src/user.ts +442 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/src/device.ts
ADDED
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
// The device agent's loop, with nothing native in it.
|
|
2
|
+
//
|
|
3
|
+
// This is the real agent — the one the Electron shell runs — and it is written
|
|
4
|
+
// here rather than in `src/desktop.ts` for one reason: everything that decides
|
|
5
|
+
// *what happens* must run in CI. So the loop holds no `child_process`, no
|
|
6
|
+
// `node:fs`, no `electron` and no ambient clock. It is handed four seams:
|
|
7
|
+
//
|
|
8
|
+
// fetch how a request leaves the laptop
|
|
9
|
+
// secrets where the machine token rests between runs (the OS keychain)
|
|
10
|
+
// runner what actually executes an op (the only untested surface)
|
|
11
|
+
// clock `now` and `sleep`, so backoff is asserted rather than waited on
|
|
12
|
+
//
|
|
13
|
+
// `MachineAgentDriverV1` in `./testing.ts` is the *stub* agent: it speaks the
|
|
14
|
+
// same wire but scripts its answers. This is the shipped one. They are checked
|
|
15
|
+
// against each other in `apps/cloudflare/test/machines-desktop.workerd.ts`,
|
|
16
|
+
// which is what "byte-identical behaviour to the stub" means in the plan.
|
|
17
|
+
//
|
|
18
|
+
// Two behaviours are worth naming because they are policy, not plumbing:
|
|
19
|
+
//
|
|
20
|
+
// 1. **A 401 un-enrols.** Revocation bumps `keyVersion`, so a revoked token
|
|
21
|
+
// fails every route forever. An agent that kept retrying it would poll a
|
|
22
|
+
// door that will never open again and would keep a dead secret on disk.
|
|
23
|
+
// The stored token is cleared and the loop stops.
|
|
24
|
+
// 2. **A command is claimed before it is run and answered after.** A claim
|
|
25
|
+
// that loses the race answers `already-claimed` and the agent does not
|
|
26
|
+
// run it: first claim wins is the protocol's guarantee against a duplicate
|
|
27
|
+
// delivery running twice, and the agent is the half that honours it.
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
MACHINE_LIMITS_V1,
|
|
31
|
+
MachineDecodeError,
|
|
32
|
+
decodeMachineClaimReceiptV1,
|
|
33
|
+
decodeMachineEnrollmentReceiptV1,
|
|
34
|
+
decodeMachineIdV1,
|
|
35
|
+
decodeMachinePollResultV1,
|
|
36
|
+
decodeMachineResultReceiptV1,
|
|
37
|
+
machineRoutePathV1,
|
|
38
|
+
type MachineCapabilityV1,
|
|
39
|
+
type MachineCommandResultV1,
|
|
40
|
+
type MachineCommandV1,
|
|
41
|
+
type MachinePlatformV1,
|
|
42
|
+
} from "@frockbot/machine-protocol";
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// What the agent remembers between runs
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The whole of the agent's durable state: which machine it is and the token
|
|
50
|
+
* that proves it. It is written to the OS secure store and nowhere else — not
|
|
51
|
+
* to a log, not to a preference file, and never back over the wire.
|
|
52
|
+
*/
|
|
53
|
+
export interface MachineEnrollmentStateV1 {
|
|
54
|
+
schemaVersion: 1;
|
|
55
|
+
machineId: string;
|
|
56
|
+
token: string;
|
|
57
|
+
/** The origin this token was minted by. A token is not portable. */
|
|
58
|
+
origin: string;
|
|
59
|
+
label: string;
|
|
60
|
+
enrolledAt: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function text(value: unknown, max: number, label: string): string {
|
|
64
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
65
|
+
throw new MachineDecodeError(`${label} must be a non-empty string`);
|
|
66
|
+
}
|
|
67
|
+
if (value.length > max) {
|
|
68
|
+
throw new MachineDecodeError(
|
|
69
|
+
`${label} exceeds ${max} characters`,
|
|
70
|
+
"limit-exceeded",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function origin(value: unknown, label: string): string {
|
|
77
|
+
const raw = text(value, 2_048, label);
|
|
78
|
+
let url: URL;
|
|
79
|
+
try {
|
|
80
|
+
url = new URL(raw);
|
|
81
|
+
} catch {
|
|
82
|
+
throw new MachineDecodeError(`${label} must be a URL`);
|
|
83
|
+
}
|
|
84
|
+
if (
|
|
85
|
+
url.origin !== raw ||
|
|
86
|
+
(url.protocol !== "https:" && url.protocol !== "http:")
|
|
87
|
+
) {
|
|
88
|
+
throw new MachineDecodeError(`${label} must be an http(s) origin`);
|
|
89
|
+
}
|
|
90
|
+
return url.origin;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function decodeMachineEnrollmentStateV1(
|
|
94
|
+
input: unknown,
|
|
95
|
+
label = "machine enrollment state",
|
|
96
|
+
): MachineEnrollmentStateV1 {
|
|
97
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
98
|
+
throw new MachineDecodeError(`${label} must be an object`);
|
|
99
|
+
}
|
|
100
|
+
const value = input as Record<string, unknown>;
|
|
101
|
+
const allowed = [
|
|
102
|
+
"schemaVersion",
|
|
103
|
+
"machineId",
|
|
104
|
+
"token",
|
|
105
|
+
"origin",
|
|
106
|
+
"label",
|
|
107
|
+
"enrolledAt",
|
|
108
|
+
];
|
|
109
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
110
|
+
if (typeof key !== "string" || !allowed.includes(key)) {
|
|
111
|
+
throw new MachineDecodeError(`${label} has an unexpected key`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (value.schemaVersion !== 1) {
|
|
115
|
+
throw new MachineDecodeError(`${label} schemaVersion must be 1`);
|
|
116
|
+
}
|
|
117
|
+
const enrolledAt = text(value.enrolledAt, 64, `${label} enrolledAt`);
|
|
118
|
+
if (Number.isNaN(Date.parse(enrolledAt))) {
|
|
119
|
+
throw new MachineDecodeError(`${label} enrolledAt must be a timestamp`);
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
schemaVersion: 1,
|
|
123
|
+
machineId: decodeMachineIdV1(value.machineId, `${label} machineId`),
|
|
124
|
+
token: text(value.token, 2_048, `${label} token`),
|
|
125
|
+
origin: origin(value.origin, `${label} origin`),
|
|
126
|
+
label: text(value.label, MACHINE_LIMITS_V1.label, `${label} label`),
|
|
127
|
+
enrolledAt,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The OS secure store, as one string.
|
|
133
|
+
*
|
|
134
|
+
* A seam and not `safeStorage` directly, because the loop above must run in
|
|
135
|
+
* CI and because a keychain that is unavailable (a headless Linux session, a
|
|
136
|
+
* locked login keychain) is a *state the agent has to survive*, not a crash.
|
|
137
|
+
*/
|
|
138
|
+
export interface MachineSecretStoreV1 {
|
|
139
|
+
read(): Promise<string | undefined>;
|
|
140
|
+
write(value: string): Promise<void>;
|
|
141
|
+
clear(): Promise<void>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A secret store that forgets on exit. Used by tests and by nothing else. */
|
|
145
|
+
export function createMemoryMachineSecretStoreV1(
|
|
146
|
+
initial?: string,
|
|
147
|
+
): MachineSecretStoreV1 {
|
|
148
|
+
let held = initial;
|
|
149
|
+
return {
|
|
150
|
+
read: () => Promise.resolve(held),
|
|
151
|
+
write: (value) => {
|
|
152
|
+
held = value;
|
|
153
|
+
return Promise.resolve();
|
|
154
|
+
},
|
|
155
|
+
clear: () => {
|
|
156
|
+
held = undefined;
|
|
157
|
+
return Promise.resolve();
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// What runs an op
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
/** A result as the machine reports it: the wire DTO minus what the caller adds. */
|
|
167
|
+
export type MachineCommandReportV1 = Omit<
|
|
168
|
+
MachineCommandResultV1,
|
|
169
|
+
"schemaVersion" | "commandId"
|
|
170
|
+
>;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The one seam that touches the laptop. `src/desktop.ts` implements it with
|
|
174
|
+
* `child_process` and `node:fs`; `src/device-runner.ts` holds every decision
|
|
175
|
+
* either of them would otherwise make.
|
|
176
|
+
*/
|
|
177
|
+
export interface MachineCommandRunnerV1 {
|
|
178
|
+
run(
|
|
179
|
+
command: MachineCommandV1,
|
|
180
|
+
signal: AbortSignal,
|
|
181
|
+
): Promise<MachineCommandReportV1>;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Backoff
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
/** The first retry delay, and the ceiling every later one is clamped to. */
|
|
189
|
+
export const MACHINE_AGENT_BACKOFF_V1 = {
|
|
190
|
+
baseMs: 1_000,
|
|
191
|
+
maxMs: 60_000,
|
|
192
|
+
/** ± this fraction of the delay, so a fleet does not retry in lockstep. */
|
|
193
|
+
jitter: 0.2,
|
|
194
|
+
} as const;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* How long the loop rests when this laptop is not paired.
|
|
198
|
+
*
|
|
199
|
+
* Not a backoff — nothing failed. An unpaired agent has no request to make, so
|
|
200
|
+
* without this the loop would spin on an early return; with it, pairing is
|
|
201
|
+
* picked up within a few seconds without the shell having to restart anything.
|
|
202
|
+
*/
|
|
203
|
+
export const MACHINE_AGENT_IDLE_MS_V1 = 5_000;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* How long to wait before the next poll after `failures` consecutive failures.
|
|
207
|
+
*
|
|
208
|
+
* Pure, and jittered from an injected `random`, so a test asserts the exact
|
|
209
|
+
* number rather than a range. `failures` of 0 is "the last poll worked": the
|
|
210
|
+
* agent does not sleep at all, because the long poll is its own pacing.
|
|
211
|
+
*/
|
|
212
|
+
export function machinePollBackoffV1(
|
|
213
|
+
failures: number,
|
|
214
|
+
random: () => number = Math.random,
|
|
215
|
+
bounds: {
|
|
216
|
+
baseMs: number;
|
|
217
|
+
maxMs: number;
|
|
218
|
+
jitter: number;
|
|
219
|
+
} = MACHINE_AGENT_BACKOFF_V1,
|
|
220
|
+
): number {
|
|
221
|
+
if (failures <= 0) return 0;
|
|
222
|
+
const exponential = bounds.baseMs * 2 ** Math.min(failures - 1, 16);
|
|
223
|
+
const clamped = Math.min(exponential, bounds.maxMs);
|
|
224
|
+
const spread = clamped * bounds.jitter;
|
|
225
|
+
// random() in [0,1) maps to [-spread, +spread).
|
|
226
|
+
return Math.max(0, Math.round(clamped + (random() * 2 - 1) * spread));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// The agent
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
export interface MachineDeviceAgentStatusV1 {
|
|
234
|
+
schemaVersion: 1;
|
|
235
|
+
enrolled: boolean;
|
|
236
|
+
running: boolean;
|
|
237
|
+
machineId?: string;
|
|
238
|
+
label?: string;
|
|
239
|
+
origin?: string;
|
|
240
|
+
/** When the last poll was answered, successfully or not. */
|
|
241
|
+
lastPollAt?: string;
|
|
242
|
+
/** Why the last cycle failed, if it did. Never carries a token. */
|
|
243
|
+
lastError?: string;
|
|
244
|
+
/** Consecutive failures, which is what the backoff is a function of. */
|
|
245
|
+
failures: number;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The status as it crosses the IPC seam into the renderer.
|
|
250
|
+
*
|
|
251
|
+
* The settings section reads this from the Electron preload bridge, which is a
|
|
252
|
+
* different runtime, so it is decoded like every other cross-runtime value.
|
|
253
|
+
* Note what is *not* on it: no token, no machine token digest, no origin
|
|
254
|
+
* secret — a status a renderer can read is a status that carries nothing that
|
|
255
|
+
* proves anything.
|
|
256
|
+
*/
|
|
257
|
+
export function decodeMachineDeviceAgentStatusV1(
|
|
258
|
+
input: unknown,
|
|
259
|
+
label = "machine agent status",
|
|
260
|
+
): MachineDeviceAgentStatusV1 {
|
|
261
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
262
|
+
throw new MachineDecodeError(`${label} must be an object`);
|
|
263
|
+
}
|
|
264
|
+
const value = input as Record<string, unknown>;
|
|
265
|
+
const allowed = [
|
|
266
|
+
"schemaVersion",
|
|
267
|
+
"enrolled",
|
|
268
|
+
"running",
|
|
269
|
+
"machineId",
|
|
270
|
+
"label",
|
|
271
|
+
"origin",
|
|
272
|
+
"lastPollAt",
|
|
273
|
+
"lastError",
|
|
274
|
+
"failures",
|
|
275
|
+
];
|
|
276
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
277
|
+
if (typeof key !== "string" || !allowed.includes(key)) {
|
|
278
|
+
throw new MachineDecodeError(`${label} has an unexpected key`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (value.schemaVersion !== 1) {
|
|
282
|
+
throw new MachineDecodeError(`${label} schemaVersion must be 1`);
|
|
283
|
+
}
|
|
284
|
+
if (
|
|
285
|
+
typeof value.enrolled !== "boolean" ||
|
|
286
|
+
typeof value.running !== "boolean"
|
|
287
|
+
) {
|
|
288
|
+
throw new MachineDecodeError(
|
|
289
|
+
`${label} enrolled and running must be booleans`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
if (
|
|
293
|
+
!Number.isSafeInteger(value.failures) ||
|
|
294
|
+
(value.failures as number) < 0 ||
|
|
295
|
+
(value.failures as number) > 1_000_000
|
|
296
|
+
) {
|
|
297
|
+
throw new MachineDecodeError(`${label} failures must be a counter`);
|
|
298
|
+
}
|
|
299
|
+
const optional = (
|
|
300
|
+
key: "machineId" | "label" | "origin" | "lastPollAt" | "lastError",
|
|
301
|
+
): Record<string, string> | Record<string, never> =>
|
|
302
|
+
value[key] === undefined
|
|
303
|
+
? {}
|
|
304
|
+
: { [key]: text(value[key], 2_048, `${label} ${key}`) };
|
|
305
|
+
return {
|
|
306
|
+
schemaVersion: 1,
|
|
307
|
+
enrolled: value.enrolled,
|
|
308
|
+
running: value.running,
|
|
309
|
+
...optional("machineId"),
|
|
310
|
+
...optional("label"),
|
|
311
|
+
...optional("origin"),
|
|
312
|
+
...optional("lastPollAt"),
|
|
313
|
+
...optional("lastError"),
|
|
314
|
+
failures: value.failures as number,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export interface MachineDeviceAgentOptionsV1 {
|
|
319
|
+
/** The deployment the machine dials. */
|
|
320
|
+
origin: string;
|
|
321
|
+
/** Injected: the platform's `fetch`. */
|
|
322
|
+
fetch(input: string, init?: RequestInit): Promise<Response>;
|
|
323
|
+
secrets: MachineSecretStoreV1;
|
|
324
|
+
runner: MachineCommandRunnerV1;
|
|
325
|
+
/** The machine's own name for itself — a hostname. */
|
|
326
|
+
label: string;
|
|
327
|
+
platform: MachinePlatformV1;
|
|
328
|
+
agentVersion: string;
|
|
329
|
+
capabilities: MachineCapabilityV1[];
|
|
330
|
+
now?(): number;
|
|
331
|
+
sleep?(ms: number, signal: AbortSignal): Promise<void>;
|
|
332
|
+
random?(): number;
|
|
333
|
+
/** Called whenever the status changes, so a UI can render it. */
|
|
334
|
+
onStatus?(status: MachineDeviceAgentStatusV1): void;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export class MachineDeviceAgentError extends Error {
|
|
338
|
+
override readonly name = "MachineDeviceAgentError";
|
|
339
|
+
readonly status: number;
|
|
340
|
+
constructor(status: number, message: string) {
|
|
341
|
+
super(message);
|
|
342
|
+
this.status = status;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** One poll-claim-run-report cycle's outcome, for tests and for the status. */
|
|
347
|
+
export interface MachineDeviceAgentCycleV1 {
|
|
348
|
+
/** False when there is no stored enrollment: nothing was attempted. */
|
|
349
|
+
paired: boolean;
|
|
350
|
+
delivered: number;
|
|
351
|
+
claimed: number;
|
|
352
|
+
alreadyClaimed: number;
|
|
353
|
+
reported: number;
|
|
354
|
+
/** Set when the cycle failed; the loop backs off on it. */
|
|
355
|
+
error?: string;
|
|
356
|
+
/** True when a 401 un-enrolled this agent. */
|
|
357
|
+
unenrolled?: boolean;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function message(error: unknown): string {
|
|
361
|
+
return (error instanceof Error ? error.message : String(error)).slice(0, 500);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export class MachineDeviceAgentV1 {
|
|
365
|
+
private state: MachineEnrollmentStateV1 | undefined;
|
|
366
|
+
private loaded = false;
|
|
367
|
+
private running = false;
|
|
368
|
+
private failures = 0;
|
|
369
|
+
private lastPollAt: string | undefined;
|
|
370
|
+
private lastError: string | undefined;
|
|
371
|
+
private loop: Promise<void> | undefined;
|
|
372
|
+
private controller: AbortController | undefined;
|
|
373
|
+
|
|
374
|
+
constructor(private readonly options: MachineDeviceAgentOptionsV1) {}
|
|
375
|
+
|
|
376
|
+
private now(): number {
|
|
377
|
+
return this.options.now?.() ?? Date.now();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private random(): number {
|
|
381
|
+
return this.options.random?.() ?? Math.random();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
385
|
+
if (this.options.sleep) return this.options.sleep(ms, signal);
|
|
386
|
+
return new Promise<void>((resolve) => {
|
|
387
|
+
if (ms <= 0 || signal.aborted) {
|
|
388
|
+
resolve();
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const timer = setTimeout(finish, ms);
|
|
392
|
+
function finish(): void {
|
|
393
|
+
clearTimeout(timer);
|
|
394
|
+
signal.removeEventListener("abort", finish);
|
|
395
|
+
resolve();
|
|
396
|
+
}
|
|
397
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* The stored enrollment, read once and then held.
|
|
403
|
+
*
|
|
404
|
+
* A store that throws — a locked keychain — is not fatal: the agent reports
|
|
405
|
+
* it as an error and stays un-enrolled, because pretending to be enrolled
|
|
406
|
+
* with no token would just 401 in a loop.
|
|
407
|
+
*/
|
|
408
|
+
private async enrollment(): Promise<MachineEnrollmentStateV1 | undefined> {
|
|
409
|
+
if (this.loaded) return this.state;
|
|
410
|
+
this.loaded = true;
|
|
411
|
+
let raw: string | undefined;
|
|
412
|
+
try {
|
|
413
|
+
raw = await this.options.secrets.read();
|
|
414
|
+
} catch (error) {
|
|
415
|
+
this.lastError = `could not read the machine token: ${message(error)}`;
|
|
416
|
+
return undefined;
|
|
417
|
+
}
|
|
418
|
+
if (raw === undefined) return undefined;
|
|
419
|
+
try {
|
|
420
|
+
const decoded = decodeMachineEnrollmentStateV1(JSON.parse(raw));
|
|
421
|
+
// A token minted by another deployment is not this one's; forget it
|
|
422
|
+
// rather than presenting it somewhere it can only fail.
|
|
423
|
+
if (decoded.origin !== this.options.origin) {
|
|
424
|
+
await this.forget();
|
|
425
|
+
return undefined;
|
|
426
|
+
}
|
|
427
|
+
this.state = decoded;
|
|
428
|
+
} catch (error) {
|
|
429
|
+
this.lastError = `stored machine enrollment is unreadable: ${message(error)}`;
|
|
430
|
+
await this.forget();
|
|
431
|
+
}
|
|
432
|
+
return this.state;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private async forget(): Promise<void> {
|
|
436
|
+
this.state = undefined;
|
|
437
|
+
try {
|
|
438
|
+
await this.options.secrets.clear();
|
|
439
|
+
} catch {
|
|
440
|
+
// A store that cannot be cleared is still forgotten in memory; the next
|
|
441
|
+
// load discards what it finds because it will not verify either.
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Whether a token is on this laptop. Reads the store on first call. */
|
|
446
|
+
async paired(): Promise<boolean> {
|
|
447
|
+
return (await this.enrollment()) !== undefined;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
status(): MachineDeviceAgentStatusV1 {
|
|
451
|
+
return {
|
|
452
|
+
schemaVersion: 1,
|
|
453
|
+
enrolled: this.state !== undefined,
|
|
454
|
+
running: this.running,
|
|
455
|
+
...(this.state === undefined
|
|
456
|
+
? {}
|
|
457
|
+
: {
|
|
458
|
+
machineId: this.state.machineId,
|
|
459
|
+
label: this.state.label,
|
|
460
|
+
origin: this.state.origin,
|
|
461
|
+
}),
|
|
462
|
+
...(this.lastPollAt === undefined ? {} : { lastPollAt: this.lastPollAt }),
|
|
463
|
+
...(this.lastError === undefined ? {} : { lastError: this.lastError }),
|
|
464
|
+
failures: this.failures,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private announce(): void {
|
|
469
|
+
this.options.onStatus?.(this.status());
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private async call(
|
|
473
|
+
path: string,
|
|
474
|
+
init: RequestInit & { token?: string } = {},
|
|
475
|
+
): Promise<unknown> {
|
|
476
|
+
const headers = new Headers(init.headers);
|
|
477
|
+
if (init.token) headers.set("authorization", `Bearer ${init.token}`);
|
|
478
|
+
if (init.body !== undefined) {
|
|
479
|
+
headers.set("content-type", "application/json");
|
|
480
|
+
}
|
|
481
|
+
const response = await this.options.fetch(`${this.options.origin}${path}`, {
|
|
482
|
+
...init,
|
|
483
|
+
headers,
|
|
484
|
+
});
|
|
485
|
+
const body = await response.text();
|
|
486
|
+
if (!response.ok) {
|
|
487
|
+
throw new MachineDeviceAgentError(
|
|
488
|
+
response.status,
|
|
489
|
+
`machine request failed with ${response.status}: ${body.slice(0, 200)}`,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
return body.length === 0 ? undefined : (JSON.parse(body) as unknown);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Present a pairing code and become a registered machine.
|
|
497
|
+
*
|
|
498
|
+
* The code is the only secret that crosses from the browser to the laptop,
|
|
499
|
+
* it is one-time, and it is never stored: what is stored is the token the
|
|
500
|
+
* enrollment answered with.
|
|
501
|
+
*/
|
|
502
|
+
async pair(code: string): Promise<MachineDeviceAgentStatusV1> {
|
|
503
|
+
const presented = text(
|
|
504
|
+
code.trim(),
|
|
505
|
+
MACHINE_LIMITS_V1.pairingCode,
|
|
506
|
+
"pairing code",
|
|
507
|
+
);
|
|
508
|
+
const receipt = decodeMachineEnrollmentReceiptV1(
|
|
509
|
+
await this.call(machineRoutePathV1("enroll"), {
|
|
510
|
+
method: "POST",
|
|
511
|
+
token: presented,
|
|
512
|
+
body: JSON.stringify({
|
|
513
|
+
schemaVersion: 1,
|
|
514
|
+
code: presented,
|
|
515
|
+
label: this.options.label,
|
|
516
|
+
platform: this.options.platform,
|
|
517
|
+
agentVersion: this.options.agentVersion,
|
|
518
|
+
capabilities: this.options.capabilities,
|
|
519
|
+
}),
|
|
520
|
+
}),
|
|
521
|
+
);
|
|
522
|
+
const state: MachineEnrollmentStateV1 = {
|
|
523
|
+
schemaVersion: 1,
|
|
524
|
+
machineId: receipt.machineId,
|
|
525
|
+
token: receipt.token,
|
|
526
|
+
origin: this.options.origin,
|
|
527
|
+
label: this.options.label,
|
|
528
|
+
enrolledAt: new Date(this.now()).toISOString(),
|
|
529
|
+
};
|
|
530
|
+
await this.options.secrets.write(JSON.stringify(state));
|
|
531
|
+
this.state = state;
|
|
532
|
+
this.loaded = true;
|
|
533
|
+
this.failures = 0;
|
|
534
|
+
this.lastError = undefined;
|
|
535
|
+
this.announce();
|
|
536
|
+
return this.status();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Forget the token on this laptop. The registry row is the browser's to revoke. */
|
|
540
|
+
async unpair(): Promise<MachineDeviceAgentStatusV1> {
|
|
541
|
+
await this.stop();
|
|
542
|
+
await this.forget();
|
|
543
|
+
this.loaded = true;
|
|
544
|
+
this.failures = 0;
|
|
545
|
+
this.lastError = undefined;
|
|
546
|
+
this.announce();
|
|
547
|
+
return this.status();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* One cycle: poll, then claim, run and answer everything the poll returned.
|
|
552
|
+
*
|
|
553
|
+
* Never throws. Every failure is a value, because the loop's job is to keep
|
|
554
|
+
* polling and a thrown error in a background loop is a silently dead agent.
|
|
555
|
+
*/
|
|
556
|
+
async runOnce(
|
|
557
|
+
waitSeconds: number = MACHINE_LIMITS_V1.pollMaxWaitSeconds,
|
|
558
|
+
signal: AbortSignal = new AbortController().signal,
|
|
559
|
+
): Promise<MachineDeviceAgentCycleV1> {
|
|
560
|
+
const cycle: MachineDeviceAgentCycleV1 = {
|
|
561
|
+
paired: true,
|
|
562
|
+
delivered: 0,
|
|
563
|
+
claimed: 0,
|
|
564
|
+
alreadyClaimed: 0,
|
|
565
|
+
reported: 0,
|
|
566
|
+
};
|
|
567
|
+
const state = await this.enrollment();
|
|
568
|
+
if (!state) {
|
|
569
|
+
cycle.paired = false;
|
|
570
|
+
cycle.error = this.lastError ?? "this machine is not paired";
|
|
571
|
+
return cycle;
|
|
572
|
+
}
|
|
573
|
+
try {
|
|
574
|
+
const answered = decodeMachinePollResultV1(
|
|
575
|
+
await this.call(
|
|
576
|
+
machineRoutePathV1("poll", {
|
|
577
|
+
machineId: state.machineId,
|
|
578
|
+
waitSeconds: Math.min(
|
|
579
|
+
waitSeconds,
|
|
580
|
+
MACHINE_LIMITS_V1.pollMaxWaitSeconds,
|
|
581
|
+
),
|
|
582
|
+
}),
|
|
583
|
+
{ token: state.token, signal },
|
|
584
|
+
),
|
|
585
|
+
);
|
|
586
|
+
this.lastPollAt = new Date(this.now()).toISOString();
|
|
587
|
+
cycle.delivered = answered.commands.length;
|
|
588
|
+
for (const command of answered.commands) {
|
|
589
|
+
if (signal.aborted) break;
|
|
590
|
+
const receipt = decodeMachineClaimReceiptV1(
|
|
591
|
+
await this.call(
|
|
592
|
+
machineRoutePathV1("claim", {
|
|
593
|
+
machineId: state.machineId,
|
|
594
|
+
commandId: command.commandId,
|
|
595
|
+
}),
|
|
596
|
+
{ method: "POST", token: state.token, body: JSON.stringify({}) },
|
|
597
|
+
),
|
|
598
|
+
);
|
|
599
|
+
if (receipt.status !== "claimed") {
|
|
600
|
+
// Somebody else holds the lease. Running it anyway is the one thing
|
|
601
|
+
// "recovery never silently duplicates" forbids.
|
|
602
|
+
cycle.alreadyClaimed += 1;
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
cycle.claimed += 1;
|
|
606
|
+
const report = await this.execute(command, signal);
|
|
607
|
+
decodeMachineResultReceiptV1(
|
|
608
|
+
await this.call(
|
|
609
|
+
machineRoutePathV1("result", {
|
|
610
|
+
machineId: state.machineId,
|
|
611
|
+
commandId: command.commandId,
|
|
612
|
+
}),
|
|
613
|
+
{
|
|
614
|
+
method: "POST",
|
|
615
|
+
token: state.token,
|
|
616
|
+
body: JSON.stringify({
|
|
617
|
+
schemaVersion: 1,
|
|
618
|
+
commandId: command.commandId,
|
|
619
|
+
...report,
|
|
620
|
+
}),
|
|
621
|
+
},
|
|
622
|
+
),
|
|
623
|
+
);
|
|
624
|
+
cycle.reported += 1;
|
|
625
|
+
}
|
|
626
|
+
this.failures = 0;
|
|
627
|
+
this.lastError = undefined;
|
|
628
|
+
} catch (error) {
|
|
629
|
+
cycle.error = message(error);
|
|
630
|
+
this.lastError = cycle.error;
|
|
631
|
+
this.failures += 1;
|
|
632
|
+
if (
|
|
633
|
+
error instanceof MachineDeviceAgentError &&
|
|
634
|
+
(error.status === 401 || error.status === 403)
|
|
635
|
+
) {
|
|
636
|
+
// Revoked, or signed with a rotated secret. The token is dead for
|
|
637
|
+
// good, so it is cleared rather than retried forever.
|
|
638
|
+
await this.forget();
|
|
639
|
+
this.running = false;
|
|
640
|
+
cycle.unenrolled = true;
|
|
641
|
+
this.lastError = "this machine was revoked; pair it again to reconnect";
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
this.announce();
|
|
645
|
+
return cycle;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* The runner, wrapped so a thrown handler is still an answer.
|
|
650
|
+
*
|
|
651
|
+
* A command that was claimed and never answered leaves the backend guessing
|
|
652
|
+
* until the lease lapses. An `error` outcome the Bot can read is strictly
|
|
653
|
+
* better than silence, so nothing the runner does escapes this method.
|
|
654
|
+
*/
|
|
655
|
+
private async execute(
|
|
656
|
+
command: MachineCommandV1,
|
|
657
|
+
signal: AbortSignal,
|
|
658
|
+
): Promise<MachineCommandReportV1> {
|
|
659
|
+
try {
|
|
660
|
+
return await this.options.runner.run(command, signal);
|
|
661
|
+
} catch (error) {
|
|
662
|
+
return {
|
|
663
|
+
finishedAt: new Date(this.now()).toISOString(),
|
|
664
|
+
outcome: "error",
|
|
665
|
+
truncated: false,
|
|
666
|
+
message: message(error),
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** Start the loop. Idempotent: a second call is not a second loop. */
|
|
672
|
+
start(): void {
|
|
673
|
+
if (this.running) return;
|
|
674
|
+
this.running = true;
|
|
675
|
+
const controller = new AbortController();
|
|
676
|
+
this.controller = controller;
|
|
677
|
+
this.loop = this.pump(controller.signal).finally(() => {
|
|
678
|
+
this.running = false;
|
|
679
|
+
this.controller = undefined;
|
|
680
|
+
this.loop = undefined;
|
|
681
|
+
});
|
|
682
|
+
this.announce();
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async stop(): Promise<void> {
|
|
686
|
+
this.controller?.abort();
|
|
687
|
+
this.running = false;
|
|
688
|
+
const loop = this.loop;
|
|
689
|
+
if (loop) await loop;
|
|
690
|
+
this.announce();
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
private async pump(signal: AbortSignal): Promise<void> {
|
|
694
|
+
while (!signal.aborted) {
|
|
695
|
+
const cycle = await this.runOnce(
|
|
696
|
+
MACHINE_LIMITS_V1.pollMaxWaitSeconds,
|
|
697
|
+
signal,
|
|
698
|
+
);
|
|
699
|
+
if (cycle.unenrolled) return;
|
|
700
|
+
if (signal.aborted) return;
|
|
701
|
+
const delay = cycle.paired
|
|
702
|
+
? machinePollBackoffV1(this.failures, () => this.random())
|
|
703
|
+
: MACHINE_AGENT_IDLE_MS_V1;
|
|
704
|
+
if (delay > 0) await this.sleep(delay, signal);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|