@braedonsaunders/appkit-desk 0.4.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/LICENSE +661 -0
- package/README.md +233 -0
- package/backend.d.ts +139 -0
- package/backend.d.ts.map +1 -0
- package/backend.js +583 -0
- package/backend.js.map +1 -0
- package/conformance.d.ts +19 -0
- package/conformance.d.ts.map +1 -0
- package/conformance.js +46 -0
- package/conformance.js.map +1 -0
- package/events.d.ts +190 -0
- package/events.d.ts.map +1 -0
- package/events.js +12 -0
- package/events.js.map +1 -0
- package/frames.d.ts +14 -0
- package/frames.d.ts.map +1 -0
- package/frames.js +24 -0
- package/frames.js.map +1 -0
- package/guest-agent.d.ts +129 -0
- package/guest-agent.d.ts.map +1 -0
- package/guest-agent.js +165 -0
- package/guest-agent.js.map +1 -0
- package/host.d.ts +325 -0
- package/host.d.ts.map +1 -0
- package/host.js +1038 -0
- package/host.js.map +1 -0
- package/index.d.ts +10 -0
- package/index.d.ts.map +1 -0
- package/index.js +10 -0
- package/index.js.map +1 -0
- package/package.json +41 -0
- package/plan.d.ts +117 -0
- package/plan.d.ts.map +1 -0
- package/plan.js +187 -0
- package/plan.js.map +1 -0
- package/portable-home.d.ts +79 -0
- package/portable-home.d.ts.map +1 -0
- package/portable-home.js +193 -0
- package/portable-home.js.map +1 -0
- package/protocol.d.ts +224 -0
- package/protocol.d.ts.map +1 -0
- package/protocol.js +498 -0
- package/protocol.js.map +1 -0
package/host.js
ADDED
|
@@ -0,0 +1,1038 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The desk host: lifecycle, leases, and the screen service over a backend.
|
|
3
|
+
*
|
|
4
|
+
* One host manages many desks against one capacity budget. A desk is
|
|
5
|
+
* headless by default; the screen is a service started on demand on the
|
|
6
|
+
* machine the agent is already using. The host owns lease accounting (idle
|
|
7
|
+
* suspend, a hard concurrency cap with a FIFO queue beyond it), event
|
|
8
|
+
* emission to the consumer's ports, and the handover masking contract. It
|
|
9
|
+
* never touches a database and knows nothing about tenants or approvals.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { rm } from 'node:fs/promises';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { randomUUID } from 'node:crypto';
|
|
16
|
+
import { Buffer } from 'node:buffer';
|
|
17
|
+
import { buildDeskLaunchPlan, cleanDeskId, DEFAULT_KVM_PATH, DEFAULT_VMM_PATH, DeskError, } from './plan.js';
|
|
18
|
+
import { cloudHypervisorBackend, } from './backend.js';
|
|
19
|
+
import { parseCapabilities, parseClipboardText, parseExecResult, parseHandoverBegun, parseJobStarted, parseObservation, } from './protocol.js';
|
|
20
|
+
export const DEFAULT_CAPACITY = 4;
|
|
21
|
+
export const DEFAULT_IDLE_SUSPEND_MS = 5 * 60_000;
|
|
22
|
+
export const DEFAULT_LEASE_MS = 15 * 60_000;
|
|
23
|
+
export const DEFAULT_CID_BASE = 100;
|
|
24
|
+
const MAX_BUFFERED_FRAMES = 60;
|
|
25
|
+
/**
|
|
26
|
+
* Video chunks a slow consumer may fall behind by before its stream is ended.
|
|
27
|
+
* Larger than the frame bound because a fragment is a fraction of a frame's
|
|
28
|
+
* bytes, and enforced by ENDING rather than by dropping — see the comment at
|
|
29
|
+
* the overrun.
|
|
30
|
+
*/
|
|
31
|
+
const MAX_BUFFERED_VIDEO_CHUNKS = 600;
|
|
32
|
+
const MIN_TIMER_DELAY_MS = 25;
|
|
33
|
+
/**
|
|
34
|
+
* Event kinds that must never reach the recording port while a handover is
|
|
35
|
+
* active. Human input and everything derived from watching the human act is
|
|
36
|
+
* masked; only the handover boundaries themselves cross.
|
|
37
|
+
*/
|
|
38
|
+
const HANDOVER_MASKED_KINDS = new Set([
|
|
39
|
+
'click',
|
|
40
|
+
'type',
|
|
41
|
+
'key',
|
|
42
|
+
'scroll',
|
|
43
|
+
'drag',
|
|
44
|
+
'window_focus',
|
|
45
|
+
'app_launch',
|
|
46
|
+
]);
|
|
47
|
+
export function createDeskHost(options) {
|
|
48
|
+
const ports = options.ports;
|
|
49
|
+
if (typeof ports?.onEvent !== 'function' || typeof ports?.audit !== 'function') {
|
|
50
|
+
throw new DeskError('createDeskHost requires ports with onEvent and audit functions.');
|
|
51
|
+
}
|
|
52
|
+
const backend = options.backend ?? cloudHypervisorBackend;
|
|
53
|
+
const now = options.now ?? Date.now;
|
|
54
|
+
const pathExists = options.pathExists ?? existsSync;
|
|
55
|
+
const deviceExists = options.deviceExists ?? existsSync;
|
|
56
|
+
const capacity = positiveInteger(options.capacity ?? DEFAULT_CAPACITY, 'capacity');
|
|
57
|
+
const idleSuspendMs = positiveInteger(options.idleSuspendMs ?? DEFAULT_IDLE_SUSPEND_MS, 'idleSuspendMs');
|
|
58
|
+
const defaultLeaseMs = positiveInteger(options.defaultLeaseMs ?? DEFAULT_LEASE_MS, 'defaultLeaseMs');
|
|
59
|
+
const kernelPath = options.kernelPath ?? join(options.imageRoot, 'vmlinux');
|
|
60
|
+
// Default the initramfs to <imageRoot>/initrd when present so a modular
|
|
61
|
+
// cloud kernel boots without every caller having to name it; an explicit
|
|
62
|
+
// option always wins.
|
|
63
|
+
const defaultInitramfs = join(options.imageRoot, 'initrd');
|
|
64
|
+
const initramfsPath = options.initramfsPath ?? (pathExists(defaultInitramfs) ? defaultInitramfs : undefined);
|
|
65
|
+
const kernelCmdline = options.kernelCmdline;
|
|
66
|
+
const records = new Map();
|
|
67
|
+
const queue = [];
|
|
68
|
+
let occupied = 0;
|
|
69
|
+
let nextCid = positiveInteger(options.cidBase ?? DEFAULT_CID_BASE, 'cidBase');
|
|
70
|
+
let lastStartedAt = null;
|
|
71
|
+
let lastSuspendedAt = null;
|
|
72
|
+
let lastError = null;
|
|
73
|
+
let reconnects = 0;
|
|
74
|
+
let lastReconnectAt = null;
|
|
75
|
+
let lastReconnectDeskId = null;
|
|
76
|
+
function iso(timestamp) {
|
|
77
|
+
return new Date(timestamp).toISOString();
|
|
78
|
+
}
|
|
79
|
+
function emit(record, detail) {
|
|
80
|
+
if (record.screen?.handover && HANDOVER_MASKED_KINDS.has(detail.kind))
|
|
81
|
+
return;
|
|
82
|
+
ports.onEvent({ ...detail, deskId: record.deskId, at: iso(now()) });
|
|
83
|
+
}
|
|
84
|
+
function touch(record) {
|
|
85
|
+
record.lastActivityAt = now();
|
|
86
|
+
}
|
|
87
|
+
function requireMachine(record, epoch) {
|
|
88
|
+
if (record.status !== 'resident' || record.epoch !== epoch || !record.machine) {
|
|
89
|
+
throw new DeskError(`Desk ${record.deskId} is not resident; resume it first.`);
|
|
90
|
+
}
|
|
91
|
+
return record.machine;
|
|
92
|
+
}
|
|
93
|
+
function requireScreen(record, epoch) {
|
|
94
|
+
requireMachine(record, epoch);
|
|
95
|
+
const screen = record.screen;
|
|
96
|
+
if (!screen?.running) {
|
|
97
|
+
throw new DeskError(`Desk ${record.deskId} has no screen running; call screen.start first.`);
|
|
98
|
+
}
|
|
99
|
+
return screen;
|
|
100
|
+
}
|
|
101
|
+
function assertCoordinates(record, points) {
|
|
102
|
+
const observation = record.screen?.lastObservation;
|
|
103
|
+
if (!observation) {
|
|
104
|
+
throw new DeskError('Coordinate input needs a frame of reference: call observe(), or start '
|
|
105
|
+
+ 'frames() and wait for one. Coordinates are the pixel space of the '
|
|
106
|
+
+ 'most recent of those, one to one.');
|
|
107
|
+
}
|
|
108
|
+
for (const point of points) {
|
|
109
|
+
if (!Number.isInteger(point.x)
|
|
110
|
+
|| !Number.isInteger(point.y)
|
|
111
|
+
|| point.x < 0
|
|
112
|
+
|| point.y < 0
|
|
113
|
+
|| point.x >= observation.width
|
|
114
|
+
|| point.y >= observation.height) {
|
|
115
|
+
throw new DeskError(`Coordinates (${point.x}, ${point.y}) are outside the most recent view `
|
|
116
|
+
+ `(${observation.width}x${observation.height}); observe() again before targeting.`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function armTimer(record) {
|
|
121
|
+
if (record.timer)
|
|
122
|
+
clearTimeout(record.timer);
|
|
123
|
+
record.timer = null;
|
|
124
|
+
if (record.status !== 'resident')
|
|
125
|
+
return;
|
|
126
|
+
const deadlines = [record.leaseDeadline, record.lastActivityAt + idleSuspendMs];
|
|
127
|
+
const handover = record.screen?.handover;
|
|
128
|
+
if (handover)
|
|
129
|
+
deadlines.push(handover.deadline);
|
|
130
|
+
const delayMs = Math.max(Math.min(...deadlines) - now(), MIN_TIMER_DELAY_MS);
|
|
131
|
+
record.timer = setTimeout(() => {
|
|
132
|
+
sweep()
|
|
133
|
+
.then(() => {
|
|
134
|
+
if (record.status === 'resident')
|
|
135
|
+
armTimer(record);
|
|
136
|
+
})
|
|
137
|
+
.catch((error) => {
|
|
138
|
+
lastError = errorMessage(error);
|
|
139
|
+
});
|
|
140
|
+
}, delayMs);
|
|
141
|
+
record.timer.unref();
|
|
142
|
+
}
|
|
143
|
+
async function endHandover(record, reason) {
|
|
144
|
+
const screen = record.screen;
|
|
145
|
+
const handover = screen?.handover;
|
|
146
|
+
if (!screen || !handover)
|
|
147
|
+
return;
|
|
148
|
+
screen.handover = null;
|
|
149
|
+
if (record.machine) {
|
|
150
|
+
try {
|
|
151
|
+
await record.machine.request({ op: 'handover-end' });
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
lastError = errorMessage(error);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const durationMs = Math.max(0, now() - handover.beganAt);
|
|
158
|
+
emit(record, { kind: 'handover_end', actor: handover.actor, durationMs });
|
|
159
|
+
ports.audit({
|
|
160
|
+
kind: 'handover_end',
|
|
161
|
+
deskId: record.deskId,
|
|
162
|
+
actor: handover.actor,
|
|
163
|
+
durationMs,
|
|
164
|
+
reason,
|
|
165
|
+
at: iso(now()),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
async function teardown(record) {
|
|
169
|
+
if (record.status !== 'resident')
|
|
170
|
+
return;
|
|
171
|
+
await endHandover(record, 'revoked');
|
|
172
|
+
for (const stop of [...record.streamStops])
|
|
173
|
+
stop();
|
|
174
|
+
record.streamStops.clear();
|
|
175
|
+
if (record.screen?.running) {
|
|
176
|
+
record.screen.running = false;
|
|
177
|
+
emit(record, { kind: 'screen_close' });
|
|
178
|
+
}
|
|
179
|
+
record.screen = null;
|
|
180
|
+
for (const job of record.jobs.values()) {
|
|
181
|
+
if (job.status !== 'running')
|
|
182
|
+
continue;
|
|
183
|
+
job.status = 'exited';
|
|
184
|
+
job.signal = 'SIGKILL';
|
|
185
|
+
emit(record, { kind: 'job_exit', jobId: job.jobId, exitCode: null, signal: 'SIGKILL' });
|
|
186
|
+
}
|
|
187
|
+
record.unsubscribe?.();
|
|
188
|
+
record.unsubscribe = null;
|
|
189
|
+
record.unwatchConnection?.();
|
|
190
|
+
record.unwatchConnection = null;
|
|
191
|
+
if (record.timer)
|
|
192
|
+
clearTimeout(record.timer);
|
|
193
|
+
record.timer = null;
|
|
194
|
+
const machine = record.machine;
|
|
195
|
+
record.machine = null;
|
|
196
|
+
record.status = 'suspended';
|
|
197
|
+
record.handle = null;
|
|
198
|
+
if (machine) {
|
|
199
|
+
try {
|
|
200
|
+
await machine.shutdown();
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
lastError = errorMessage(error);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
lastSuspendedAt = iso(now());
|
|
207
|
+
occupied -= 1;
|
|
208
|
+
drainQueue();
|
|
209
|
+
}
|
|
210
|
+
function drainQueue() {
|
|
211
|
+
while (occupied < capacity && queue.length > 0) {
|
|
212
|
+
const entry = queue.shift();
|
|
213
|
+
if (!entry)
|
|
214
|
+
break;
|
|
215
|
+
occupied += 1;
|
|
216
|
+
entry.run().then(entry.resolve, (error) => {
|
|
217
|
+
occupied -= 1;
|
|
218
|
+
entry.reject(error instanceof Error ? error : new Error(String(error)));
|
|
219
|
+
drainQueue();
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function admit(run) {
|
|
224
|
+
if (occupied < capacity) {
|
|
225
|
+
occupied += 1;
|
|
226
|
+
return run().catch((error) => {
|
|
227
|
+
occupied -= 1;
|
|
228
|
+
drainQueue();
|
|
229
|
+
throw error;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
233
|
+
queue.push({ run, resolve: resolvePromise, reject: rejectPromise });
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
async function boot(deskId, startOptions) {
|
|
237
|
+
const plan = buildDeskLaunchPlan({
|
|
238
|
+
deskId,
|
|
239
|
+
vmmPath: options.vmmPath,
|
|
240
|
+
kvmPath: options.kvmPath,
|
|
241
|
+
kernelPath,
|
|
242
|
+
...(initramfsPath === undefined ? {} : { initramfsPath }),
|
|
243
|
+
baseImagePath: startOptions.baseImage,
|
|
244
|
+
overlayPath: startOptions.overlayPath,
|
|
245
|
+
memoryMb: startOptions.memoryMb,
|
|
246
|
+
vcpus: startOptions.vcpus,
|
|
247
|
+
vsockCid: nextCid++,
|
|
248
|
+
runtimeDir: options.runtimeDir,
|
|
249
|
+
tapDevice: startOptions.network?.tapDevice,
|
|
250
|
+
macAddress: startOptions.network?.macAddress,
|
|
251
|
+
// Forward the host's cmdline only when set; otherwise the plan falls
|
|
252
|
+
// back to DEFAULT_KERNEL_CMDLINE.
|
|
253
|
+
...(kernelCmdline === undefined ? {} : { kernelCmdline }),
|
|
254
|
+
launcherIdentity: options.launcherIdentity,
|
|
255
|
+
}, { pathExists, deviceExists });
|
|
256
|
+
let machine;
|
|
257
|
+
try {
|
|
258
|
+
machine = await backend.boot(plan);
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
lastError = errorMessage(error);
|
|
262
|
+
throw error instanceof DeskError
|
|
263
|
+
? error
|
|
264
|
+
: new DeskError(`Desk ${deskId} failed to boot: ${errorMessage(error)}`);
|
|
265
|
+
}
|
|
266
|
+
let record = records.get(deskId);
|
|
267
|
+
if (!record) {
|
|
268
|
+
record = {
|
|
269
|
+
deskId,
|
|
270
|
+
startOptions,
|
|
271
|
+
status: 'resident',
|
|
272
|
+
machine,
|
|
273
|
+
epoch: 1,
|
|
274
|
+
leaseDeadline: 0,
|
|
275
|
+
lastActivityAt: 0,
|
|
276
|
+
jobs: new Map(),
|
|
277
|
+
screen: null,
|
|
278
|
+
unsubscribe: null,
|
|
279
|
+
unwatchConnection: null,
|
|
280
|
+
handle: null,
|
|
281
|
+
timer: null,
|
|
282
|
+
streamStops: new Set(),
|
|
283
|
+
};
|
|
284
|
+
records.set(deskId, record);
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
record.startOptions = startOptions;
|
|
288
|
+
record.status = 'resident';
|
|
289
|
+
record.machine = machine;
|
|
290
|
+
record.epoch += 1;
|
|
291
|
+
record.jobs = new Map();
|
|
292
|
+
record.screen = null;
|
|
293
|
+
record.streamStops = new Set();
|
|
294
|
+
}
|
|
295
|
+
const current = record;
|
|
296
|
+
const epoch = current.epoch;
|
|
297
|
+
current.leaseDeadline = now() + positiveInteger(startOptions.leaseMs ?? defaultLeaseMs, 'leaseMs');
|
|
298
|
+
current.lastActivityAt = now();
|
|
299
|
+
current.unsubscribe = machine.subscribe((event) => {
|
|
300
|
+
handleGuestEvent(current, epoch, event);
|
|
301
|
+
});
|
|
302
|
+
current.unwatchConnection =
|
|
303
|
+
machine.onConnectionChange?.((change) => {
|
|
304
|
+
handleConnectionChange(current, epoch, change);
|
|
305
|
+
}) ?? null;
|
|
306
|
+
current.handle = makeHandle(current, epoch);
|
|
307
|
+
lastStartedAt = iso(now());
|
|
308
|
+
armTimer(current);
|
|
309
|
+
return current.handle;
|
|
310
|
+
}
|
|
311
|
+
function handleGuestEvent(record, epoch, event) {
|
|
312
|
+
if (record.status !== 'resident' || record.epoch !== epoch)
|
|
313
|
+
return;
|
|
314
|
+
if (event.event === 'job-exit') {
|
|
315
|
+
const job = record.jobs.get(event.jobId);
|
|
316
|
+
if (!job || job.status !== 'running')
|
|
317
|
+
return;
|
|
318
|
+
job.status = 'exited';
|
|
319
|
+
job.exitCode = event.exitCode;
|
|
320
|
+
job.signal = event.signal;
|
|
321
|
+
emit(record, {
|
|
322
|
+
kind: 'job_exit',
|
|
323
|
+
jobId: event.jobId,
|
|
324
|
+
exitCode: event.exitCode,
|
|
325
|
+
signal: event.signal,
|
|
326
|
+
});
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (event.event === 'window-focus') {
|
|
330
|
+
if (record.screen)
|
|
331
|
+
record.screen.lastFocusedId = event.window.id;
|
|
332
|
+
emit(record, { kind: 'window_focus', window: event.window });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* What a desk losing (and regaining) its guest channel means up here.
|
|
337
|
+
*
|
|
338
|
+
* The backend can get the CHANNEL back; it cannot get the GUEST's state
|
|
339
|
+
* back, and pretending otherwise is how a consumer ends up waiting forever
|
|
340
|
+
* on a stream that will never produce another byte. So a reconnect is
|
|
341
|
+
* treated as a discontinuity that the caller is able to see, and a
|
|
342
|
+
* permanent loss retires the desk instead of leaving a dead one leased.
|
|
343
|
+
*/
|
|
344
|
+
function handleConnectionChange(record, epoch, change) {
|
|
345
|
+
if (record.status !== 'resident' || record.epoch !== epoch)
|
|
346
|
+
return;
|
|
347
|
+
if (change.state === 'reconnected') {
|
|
348
|
+
reconnects += 1;
|
|
349
|
+
lastReconnectAt = iso(now());
|
|
350
|
+
lastReconnectDeskId = record.deskId;
|
|
351
|
+
// Never silent. A reconnect that hides a guest agent restarting is a
|
|
352
|
+
// debugging trap: the desk misbehaves and nothing anywhere says why.
|
|
353
|
+
lastError = `Desk ${record.deskId} reconnected to its guest agent after ${change.downtimeMs}ms (${change.reason}).`;
|
|
354
|
+
// The guest's capture state did not necessarily come back with the
|
|
355
|
+
// channel. An agent that restarted has no screen, no frame encoder and
|
|
356
|
+
// no video encoder; even one that survived starts a fresh encoder whose
|
|
357
|
+
// sequence numbers and init segment do not continue the old stream. A
|
|
358
|
+
// consumer resumed mid-stream decodes nothing, silently — so every live
|
|
359
|
+
// stream is ENDED. `frames()`/`video()` complete normally, which is how
|
|
360
|
+
// the consumer notices, and it can start a new one on a guest that is
|
|
361
|
+
// now definitely capturing.
|
|
362
|
+
for (const stop of [...record.streamStops])
|
|
363
|
+
stop();
|
|
364
|
+
record.streamStops.clear();
|
|
365
|
+
// The coordinate anchor goes with them: the screen behind it may be
|
|
366
|
+
// gone, or recreated at another size, and a click aimed at the pixel
|
|
367
|
+
// space of a screen that no longer exists lands somewhere nobody chose.
|
|
368
|
+
// Input then refuses until the caller observes again — the existing
|
|
369
|
+
// contract, not a new one.
|
|
370
|
+
if (record.screen)
|
|
371
|
+
record.screen.lastObservation = null;
|
|
372
|
+
// A handover is deliberately NOT ended here. If a human is still on that
|
|
373
|
+
// screen, dropping the mask would start recording them; letting the TTL
|
|
374
|
+
// expire it fails in the safe direction.
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
// Lost for good. Left resident, this desk would answer every call with
|
|
378
|
+
// "no longer connected" for the rest of the lease and `resume()` would
|
|
379
|
+
// hand back the same dead handle — the exact product failure this release
|
|
380
|
+
// is about. Suspending it frees the capacity slot and makes the next
|
|
381
|
+
// `resume()` boot a healthy desk.
|
|
382
|
+
lastError = change.reason;
|
|
383
|
+
void teardown(record).catch((error) => {
|
|
384
|
+
lastError = errorMessage(error);
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
function makeHandle(record, epoch) {
|
|
388
|
+
const execImplementation = async (execOptions) => {
|
|
389
|
+
const machine = requireMachine(record, epoch);
|
|
390
|
+
const command = nonEmptyString(execOptions.command, 'command');
|
|
391
|
+
const args = [...(execOptions.args ?? [])];
|
|
392
|
+
const keepAlive = execOptions.keepAlive === true;
|
|
393
|
+
if (ports.policy?.allowExec && !ports.policy.allowExec({
|
|
394
|
+
deskId: record.deskId,
|
|
395
|
+
command,
|
|
396
|
+
args,
|
|
397
|
+
keepAlive,
|
|
398
|
+
})) {
|
|
399
|
+
throw new DeskError(`Policy denied exec of ${command} on desk ${record.deskId}.`);
|
|
400
|
+
}
|
|
401
|
+
touch(record);
|
|
402
|
+
const startedAt = iso(now());
|
|
403
|
+
if (keepAlive) {
|
|
404
|
+
const { jobId } = parseJobStarted(await machine.request({
|
|
405
|
+
op: 'job-start',
|
|
406
|
+
command,
|
|
407
|
+
args,
|
|
408
|
+
cwd: execOptions.cwd,
|
|
409
|
+
env: execOptions.env,
|
|
410
|
+
}));
|
|
411
|
+
const job = {
|
|
412
|
+
jobId,
|
|
413
|
+
deskId: record.deskId,
|
|
414
|
+
command,
|
|
415
|
+
args,
|
|
416
|
+
startedAt,
|
|
417
|
+
status: 'running',
|
|
418
|
+
exitCode: null,
|
|
419
|
+
signal: null,
|
|
420
|
+
};
|
|
421
|
+
record.jobs.set(jobId, job);
|
|
422
|
+
emit(record, { kind: 'job_start', jobId, command, args });
|
|
423
|
+
return { ...job };
|
|
424
|
+
}
|
|
425
|
+
const result = parseExecResult(await machine.request({
|
|
426
|
+
op: 'exec',
|
|
427
|
+
command,
|
|
428
|
+
args,
|
|
429
|
+
cwd: execOptions.cwd,
|
|
430
|
+
env: execOptions.env,
|
|
431
|
+
timeoutMs: execOptions.timeoutMs,
|
|
432
|
+
}, execOptions.timeoutMs === undefined ? {} : { timeoutMs: execOptions.timeoutMs + 5_000 }));
|
|
433
|
+
emit(record, {
|
|
434
|
+
kind: 'shell_command',
|
|
435
|
+
command,
|
|
436
|
+
args,
|
|
437
|
+
cwd: execOptions.cwd ?? null,
|
|
438
|
+
exitCode: result.exitCode,
|
|
439
|
+
signal: result.signal,
|
|
440
|
+
});
|
|
441
|
+
return {
|
|
442
|
+
deskId: record.deskId,
|
|
443
|
+
command,
|
|
444
|
+
args,
|
|
445
|
+
exitCode: result.exitCode,
|
|
446
|
+
signal: result.signal,
|
|
447
|
+
stdout: result.stdout,
|
|
448
|
+
stderr: result.stderr,
|
|
449
|
+
truncated: result.truncated,
|
|
450
|
+
startedAt,
|
|
451
|
+
finishedAt: iso(now()),
|
|
452
|
+
};
|
|
453
|
+
};
|
|
454
|
+
const screenService = {
|
|
455
|
+
async start({ width, height }) {
|
|
456
|
+
const machine = requireMachine(record, epoch);
|
|
457
|
+
if (record.screen?.running)
|
|
458
|
+
return record.screen.handle;
|
|
459
|
+
const cleanWidth = positiveInteger(width, 'width');
|
|
460
|
+
const cleanHeight = positiveInteger(height, 'height');
|
|
461
|
+
if (ports.policy?.allowScreen && !ports.policy.allowScreen({
|
|
462
|
+
deskId: record.deskId,
|
|
463
|
+
width: cleanWidth,
|
|
464
|
+
height: cleanHeight,
|
|
465
|
+
})) {
|
|
466
|
+
throw new DeskError(`Policy denied opening a screen on desk ${record.deskId}.`);
|
|
467
|
+
}
|
|
468
|
+
touch(record);
|
|
469
|
+
await machine.request({ op: 'screen-start', width: cleanWidth, height: cleanHeight });
|
|
470
|
+
const handle = makeScreenHandle(record, epoch);
|
|
471
|
+
record.screen = {
|
|
472
|
+
running: true,
|
|
473
|
+
width: cleanWidth,
|
|
474
|
+
height: cleanHeight,
|
|
475
|
+
lastObservation: null,
|
|
476
|
+
lastFocusedId: null,
|
|
477
|
+
handover: null,
|
|
478
|
+
handle,
|
|
479
|
+
};
|
|
480
|
+
emit(record, { kind: 'screen_open', width: cleanWidth, height: cleanHeight });
|
|
481
|
+
return handle;
|
|
482
|
+
},
|
|
483
|
+
async stop() {
|
|
484
|
+
const machine = requireMachine(record, epoch);
|
|
485
|
+
const screen = record.screen;
|
|
486
|
+
if (!screen?.running)
|
|
487
|
+
return;
|
|
488
|
+
await endHandover(record, 'revoked');
|
|
489
|
+
for (const stop of [...record.streamStops])
|
|
490
|
+
stop();
|
|
491
|
+
record.streamStops.clear();
|
|
492
|
+
screen.running = false;
|
|
493
|
+
record.screen = null;
|
|
494
|
+
touch(record);
|
|
495
|
+
await machine.request({ op: 'screen-stop' });
|
|
496
|
+
emit(record, { kind: 'screen_close' });
|
|
497
|
+
},
|
|
498
|
+
get running() {
|
|
499
|
+
return record.screen?.running === true;
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
return {
|
|
503
|
+
deskId: record.deskId,
|
|
504
|
+
renewLease(ms) {
|
|
505
|
+
requireMachine(record, epoch);
|
|
506
|
+
record.leaseDeadline = now() + positiveInteger(ms, 'ms');
|
|
507
|
+
touch(record);
|
|
508
|
+
armTimer(record);
|
|
509
|
+
},
|
|
510
|
+
exec: execImplementation,
|
|
511
|
+
jobs() {
|
|
512
|
+
return [...record.jobs.values()]
|
|
513
|
+
.filter((job) => job.status === 'running')
|
|
514
|
+
.map((job) => ({ ...job }));
|
|
515
|
+
},
|
|
516
|
+
screen: screenService,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
function makeScreenHandle(record, epoch) {
|
|
520
|
+
async function sendInput(detail, input) {
|
|
521
|
+
const machine = requireMachine(record, epoch);
|
|
522
|
+
touch(record);
|
|
523
|
+
await machine.request({ op: 'input', input });
|
|
524
|
+
if (detail)
|
|
525
|
+
emit(record, detail);
|
|
526
|
+
}
|
|
527
|
+
return {
|
|
528
|
+
async observe() {
|
|
529
|
+
const machine = requireMachine(record, epoch);
|
|
530
|
+
const screen = requireScreen(record, epoch);
|
|
531
|
+
touch(record);
|
|
532
|
+
const observation = parseObservation(await machine.request({ op: 'observe' }));
|
|
533
|
+
screen.lastObservation = { width: observation.width, height: observation.height };
|
|
534
|
+
if (observation.focused && observation.focused.id !== screen.lastFocusedId) {
|
|
535
|
+
screen.lastFocusedId = observation.focused.id;
|
|
536
|
+
emit(record, { kind: 'window_focus', window: observation.focused });
|
|
537
|
+
}
|
|
538
|
+
return {
|
|
539
|
+
png: Buffer.from(observation.png, 'base64'),
|
|
540
|
+
width: observation.width,
|
|
541
|
+
height: observation.height,
|
|
542
|
+
a11y: observation.a11y,
|
|
543
|
+
windows: observation.windows,
|
|
544
|
+
focused: observation.focused,
|
|
545
|
+
};
|
|
546
|
+
},
|
|
547
|
+
input: {
|
|
548
|
+
async move(x, y) {
|
|
549
|
+
requireScreen(record, epoch);
|
|
550
|
+
assertCoordinates(record, [{ x, y }]);
|
|
551
|
+
await sendInput(null, { type: 'move', x, y });
|
|
552
|
+
},
|
|
553
|
+
async click(x, y, button = 'left') {
|
|
554
|
+
requireScreen(record, epoch);
|
|
555
|
+
assertCoordinates(record, [{ x, y }]);
|
|
556
|
+
await sendInput({ kind: 'click', x, y, button }, { type: 'click', x, y, button });
|
|
557
|
+
},
|
|
558
|
+
async type(text) {
|
|
559
|
+
requireScreen(record, epoch);
|
|
560
|
+
const cleanText = requireStringValue(text, 'text');
|
|
561
|
+
await sendInput({ kind: 'type', text: cleanText }, { type: 'type', text: cleanText });
|
|
562
|
+
},
|
|
563
|
+
async key(combo) {
|
|
564
|
+
requireScreen(record, epoch);
|
|
565
|
+
const cleanCombo = nonEmptyString(combo, 'combo');
|
|
566
|
+
await sendInput({ kind: 'key', combo: cleanCombo }, { type: 'key', combo: cleanCombo });
|
|
567
|
+
},
|
|
568
|
+
async scroll(x, y, dx, dy) {
|
|
569
|
+
requireScreen(record, epoch);
|
|
570
|
+
assertCoordinates(record, [{ x, y }]);
|
|
571
|
+
if (!Number.isInteger(dx) || !Number.isInteger(dy)) {
|
|
572
|
+
throw new DeskError('Scroll deltas must be integers.');
|
|
573
|
+
}
|
|
574
|
+
await sendInput({ kind: 'scroll', x, y, dx, dy }, { type: 'scroll', x, y, dx, dy });
|
|
575
|
+
},
|
|
576
|
+
async drag(from, to) {
|
|
577
|
+
requireScreen(record, epoch);
|
|
578
|
+
assertCoordinates(record, [from, to]);
|
|
579
|
+
await sendInput({ kind: 'drag', from, to }, { type: 'drag', from, to });
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
a11y: {
|
|
583
|
+
async invoke(nodeId, action) {
|
|
584
|
+
const machine = requireMachine(record, epoch);
|
|
585
|
+
requireScreen(record, epoch);
|
|
586
|
+
touch(record);
|
|
587
|
+
await machine.request({
|
|
588
|
+
op: 'a11y-invoke',
|
|
589
|
+
nodeId: nonEmptyString(nodeId, 'nodeId'),
|
|
590
|
+
action: nonEmptyString(action, 'action'),
|
|
591
|
+
});
|
|
592
|
+
},
|
|
593
|
+
},
|
|
594
|
+
async launch(appId, args = []) {
|
|
595
|
+
const machine = requireMachine(record, epoch);
|
|
596
|
+
requireScreen(record, epoch);
|
|
597
|
+
touch(record);
|
|
598
|
+
const cleanAppId = nonEmptyString(appId, 'appId');
|
|
599
|
+
await machine.request({ op: 'launch', appId: cleanAppId, args: [...args] });
|
|
600
|
+
emit(record, { kind: 'app_launch', appId: cleanAppId, args: [...args] });
|
|
601
|
+
},
|
|
602
|
+
clipboard: {
|
|
603
|
+
async read() {
|
|
604
|
+
const machine = requireMachine(record, epoch);
|
|
605
|
+
const screen = requireScreen(record, epoch);
|
|
606
|
+
if (screen.handover) {
|
|
607
|
+
// The human may have just typed a credential that is now on the
|
|
608
|
+
// clipboard; reading it during a handover would carry it into the
|
|
609
|
+
// agent's context and the consumer's record.
|
|
610
|
+
throw new DeskError('The clipboard is masked while a handover is active.');
|
|
611
|
+
}
|
|
612
|
+
touch(record);
|
|
613
|
+
return parseClipboardText(await machine.request({ op: 'clipboard-read' }));
|
|
614
|
+
},
|
|
615
|
+
async write(text) {
|
|
616
|
+
const machine = requireMachine(record, epoch);
|
|
617
|
+
requireScreen(record, epoch);
|
|
618
|
+
touch(record);
|
|
619
|
+
await machine.request({ op: 'clipboard-write', text: requireStringValue(text, 'text') });
|
|
620
|
+
},
|
|
621
|
+
},
|
|
622
|
+
frames(frameOptions = {}) {
|
|
623
|
+
return {
|
|
624
|
+
[Symbol.asyncIterator]: () => {
|
|
625
|
+
const machine = requireMachine(record, epoch);
|
|
626
|
+
const screen = requireScreen(record, epoch);
|
|
627
|
+
const fps = positiveInteger(frameOptions.fps ?? 10, 'fps');
|
|
628
|
+
const width = positiveInteger(frameOptions.width ?? screen.width, 'width');
|
|
629
|
+
const height = positiveInteger(frameOptions.height ?? screen.height, 'height');
|
|
630
|
+
const format = frameOptions.format;
|
|
631
|
+
const buffered = [];
|
|
632
|
+
let waiting = null;
|
|
633
|
+
let finished = false;
|
|
634
|
+
const unsubscribe = machine.subscribe((event) => {
|
|
635
|
+
if (finished || event.event !== 'frame')
|
|
636
|
+
return;
|
|
637
|
+
// Masking contract: no frame reaches a recording consumer while
|
|
638
|
+
// a handover is active.
|
|
639
|
+
if (record.screen?.handover)
|
|
640
|
+
return;
|
|
641
|
+
const frame = {
|
|
642
|
+
seq: event.seq,
|
|
643
|
+
width: event.width,
|
|
644
|
+
height: event.height,
|
|
645
|
+
data: Buffer.from(event.data, 'base64'),
|
|
646
|
+
format: event.format,
|
|
647
|
+
at: iso(now()),
|
|
648
|
+
};
|
|
649
|
+
// A frame anchors coordinates exactly as an observation does.
|
|
650
|
+
// The contract is that a click lands in the pixel space of the
|
|
651
|
+
// most recent thing the caller was SHOWN, and a live view is
|
|
652
|
+
// shown the frame stream, not observe(). Without this a viewer
|
|
653
|
+
// that watches frames and clicks what it sees is refused for
|
|
654
|
+
// never having called observe() — technically true and useless.
|
|
655
|
+
if (record.screen) {
|
|
656
|
+
record.screen.lastObservation = { width: frame.width, height: frame.height };
|
|
657
|
+
}
|
|
658
|
+
if (waiting) {
|
|
659
|
+
const resume = waiting;
|
|
660
|
+
waiting = null;
|
|
661
|
+
resume({ value: frame, done: false });
|
|
662
|
+
}
|
|
663
|
+
else {
|
|
664
|
+
buffered.push(frame);
|
|
665
|
+
if (buffered.length > MAX_BUFFERED_FRAMES)
|
|
666
|
+
buffered.shift();
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
const finish = () => {
|
|
670
|
+
if (finished)
|
|
671
|
+
return;
|
|
672
|
+
finished = true;
|
|
673
|
+
unsubscribe();
|
|
674
|
+
record.streamStops.delete(finish);
|
|
675
|
+
if (record.status === 'resident' && record.machine) {
|
|
676
|
+
record.machine.request({ op: 'frames-stop' }).catch((error) => {
|
|
677
|
+
lastError = errorMessage(error);
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
if (waiting) {
|
|
681
|
+
const resume = waiting;
|
|
682
|
+
waiting = null;
|
|
683
|
+
resume({ value: undefined, done: true });
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
record.streamStops.add(finish);
|
|
687
|
+
const started = machine
|
|
688
|
+
.request({ op: 'frames-start', fps, width, height, format })
|
|
689
|
+
.catch((error) => {
|
|
690
|
+
finish();
|
|
691
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
692
|
+
});
|
|
693
|
+
return {
|
|
694
|
+
async next() {
|
|
695
|
+
await started;
|
|
696
|
+
const bufferedFrame = buffered.shift();
|
|
697
|
+
if (bufferedFrame)
|
|
698
|
+
return { value: bufferedFrame, done: false };
|
|
699
|
+
if (finished)
|
|
700
|
+
return { value: undefined, done: true };
|
|
701
|
+
return new Promise((resolvePromise) => {
|
|
702
|
+
waiting = resolvePromise;
|
|
703
|
+
});
|
|
704
|
+
},
|
|
705
|
+
async return() {
|
|
706
|
+
finish();
|
|
707
|
+
return { value: undefined, done: true };
|
|
708
|
+
},
|
|
709
|
+
async throw(error) {
|
|
710
|
+
finish();
|
|
711
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
712
|
+
},
|
|
713
|
+
};
|
|
714
|
+
},
|
|
715
|
+
};
|
|
716
|
+
},
|
|
717
|
+
video(videoOptions = {}) {
|
|
718
|
+
return {
|
|
719
|
+
[Symbol.asyncIterator]: () => {
|
|
720
|
+
const machine = requireMachine(record, epoch);
|
|
721
|
+
const screen = requireScreen(record, epoch);
|
|
722
|
+
const fps = positiveInteger(videoOptions.fps ?? 30, 'fps');
|
|
723
|
+
const width = positiveInteger(videoOptions.width ?? screen.width, 'width');
|
|
724
|
+
const height = positiveInteger(videoOptions.height ?? screen.height, 'height');
|
|
725
|
+
const buffered = [];
|
|
726
|
+
let waiting = null;
|
|
727
|
+
let finished = false;
|
|
728
|
+
const unsubscribe = machine.subscribe((event) => {
|
|
729
|
+
if (finished || event.event !== 'video-chunk')
|
|
730
|
+
return;
|
|
731
|
+
// The same masking contract frames are held to: while a handover
|
|
732
|
+
// is active a person is on that screen, and nothing derived from
|
|
733
|
+
// watching them may reach a recording consumer.
|
|
734
|
+
if (record.screen?.handover)
|
|
735
|
+
return;
|
|
736
|
+
const chunk = {
|
|
737
|
+
seq: event.seq,
|
|
738
|
+
kind: event.kind,
|
|
739
|
+
codec: event.codec,
|
|
740
|
+
width: event.width,
|
|
741
|
+
height: event.height,
|
|
742
|
+
keyframe: event.keyframe,
|
|
743
|
+
data: Buffer.from(event.data, 'base64'),
|
|
744
|
+
at: iso(now()),
|
|
745
|
+
};
|
|
746
|
+
// A video chunk anchors coordinates exactly as a frame does, and
|
|
747
|
+
// for the same reason: a viewer driving by video is aiming at the
|
|
748
|
+
// pixel space the video showed it. Anchoring on every chunk, not
|
|
749
|
+
// only the first, is what re-asserts the space when the encoder
|
|
750
|
+
// restarts at a new resolution.
|
|
751
|
+
if (record.screen) {
|
|
752
|
+
record.screen.lastObservation = { width: chunk.width, height: chunk.height };
|
|
753
|
+
}
|
|
754
|
+
if (waiting) {
|
|
755
|
+
const resume = waiting;
|
|
756
|
+
waiting = null;
|
|
757
|
+
resume({ value: chunk, done: false });
|
|
758
|
+
}
|
|
759
|
+
else {
|
|
760
|
+
buffered.push(chunk);
|
|
761
|
+
// Dropping the OLDEST would drop the init segment, and a
|
|
762
|
+
// consumer without it decodes nothing at all — so an overrun
|
|
763
|
+
// ends the stream instead. A caller that cannot keep up with a
|
|
764
|
+
// video stream is one that should re-subscribe and be resynced
|
|
765
|
+
// from a keyframe, not one that should be handed a hole.
|
|
766
|
+
if (buffered.length > MAX_BUFFERED_VIDEO_CHUNKS)
|
|
767
|
+
finish();
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
const finish = () => {
|
|
771
|
+
if (finished)
|
|
772
|
+
return;
|
|
773
|
+
finished = true;
|
|
774
|
+
unsubscribe();
|
|
775
|
+
record.streamStops.delete(finish);
|
|
776
|
+
if (record.status === 'resident' && record.machine) {
|
|
777
|
+
record.machine.request({ op: 'video-stop' }).catch((error) => {
|
|
778
|
+
lastError = errorMessage(error);
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
if (waiting) {
|
|
782
|
+
const resume = waiting;
|
|
783
|
+
waiting = null;
|
|
784
|
+
resume({ value: undefined, done: true });
|
|
785
|
+
}
|
|
786
|
+
};
|
|
787
|
+
record.streamStops.add(finish);
|
|
788
|
+
const started = machine
|
|
789
|
+
.request({ op: 'video-start', fps, width, height })
|
|
790
|
+
.catch((error) => {
|
|
791
|
+
finish();
|
|
792
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
793
|
+
});
|
|
794
|
+
return {
|
|
795
|
+
async next() {
|
|
796
|
+
await started;
|
|
797
|
+
const bufferedChunk = buffered.shift();
|
|
798
|
+
if (bufferedChunk)
|
|
799
|
+
return { value: bufferedChunk, done: false };
|
|
800
|
+
if (finished)
|
|
801
|
+
return { value: undefined, done: true };
|
|
802
|
+
return new Promise((resolvePromise) => {
|
|
803
|
+
waiting = resolvePromise;
|
|
804
|
+
});
|
|
805
|
+
},
|
|
806
|
+
async return() {
|
|
807
|
+
finish();
|
|
808
|
+
return { value: undefined, done: true };
|
|
809
|
+
},
|
|
810
|
+
async throw(error) {
|
|
811
|
+
finish();
|
|
812
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
813
|
+
},
|
|
814
|
+
};
|
|
815
|
+
},
|
|
816
|
+
};
|
|
817
|
+
},
|
|
818
|
+
handover: {
|
|
819
|
+
async begin({ ttlMs, scope = 'control', actor }) {
|
|
820
|
+
const machine = requireMachine(record, epoch);
|
|
821
|
+
const screen = requireScreen(record, epoch);
|
|
822
|
+
if (screen.handover) {
|
|
823
|
+
throw new DeskError(`A handover is already active on desk ${record.deskId}.`);
|
|
824
|
+
}
|
|
825
|
+
const cleanTtl = positiveInteger(ttlMs, 'ttlMs');
|
|
826
|
+
if (ports.policy?.allowHandover && !ports.policy.allowHandover({
|
|
827
|
+
deskId: record.deskId,
|
|
828
|
+
scope,
|
|
829
|
+
ttlMs: cleanTtl,
|
|
830
|
+
})) {
|
|
831
|
+
throw new DeskError(`Policy denied a handover on desk ${record.deskId}.`);
|
|
832
|
+
}
|
|
833
|
+
touch(record);
|
|
834
|
+
const begun = parseHandoverBegun(await machine.request({ op: 'handover-begin', ttlMs: cleanTtl, scope }));
|
|
835
|
+
const startedAt = now();
|
|
836
|
+
screen.handover = {
|
|
837
|
+
actor: actor ?? null,
|
|
838
|
+
scope,
|
|
839
|
+
beganAt: startedAt,
|
|
840
|
+
deadline: startedAt + cleanTtl,
|
|
841
|
+
};
|
|
842
|
+
// The boundary events are the one thing that crosses the mask.
|
|
843
|
+
emit(record, { kind: 'handover_begin', actor: actor ?? null, scope });
|
|
844
|
+
ports.audit({
|
|
845
|
+
kind: 'handover_begin',
|
|
846
|
+
deskId: record.deskId,
|
|
847
|
+
actor: actor ?? null,
|
|
848
|
+
scope,
|
|
849
|
+
ttlMs: cleanTtl,
|
|
850
|
+
at: iso(startedAt),
|
|
851
|
+
});
|
|
852
|
+
armTimer(record);
|
|
853
|
+
return { url: begun.url };
|
|
854
|
+
},
|
|
855
|
+
async end() {
|
|
856
|
+
requireMachine(record, epoch);
|
|
857
|
+
await endHandover(record, 'ended');
|
|
858
|
+
},
|
|
859
|
+
get active() {
|
|
860
|
+
return record.screen?.handover != null;
|
|
861
|
+
},
|
|
862
|
+
},
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
async function start(startOptions) {
|
|
866
|
+
const deskId = cleanDeskId(startOptions.deskId);
|
|
867
|
+
const existing = records.get(deskId);
|
|
868
|
+
if (existing?.status === 'resident' && existing.handle)
|
|
869
|
+
return existing.handle;
|
|
870
|
+
return admit(() => boot(deskId, { ...startOptions, deskId }));
|
|
871
|
+
}
|
|
872
|
+
async function resume(deskId) {
|
|
873
|
+
const record = records.get(cleanDeskId(deskId));
|
|
874
|
+
if (!record)
|
|
875
|
+
throw new DeskError(`Unknown desk: ${deskId}`);
|
|
876
|
+
if (record.status === 'resident' && record.handle)
|
|
877
|
+
return record.handle;
|
|
878
|
+
return admit(() => boot(record.deskId, record.startOptions));
|
|
879
|
+
}
|
|
880
|
+
async function suspend(deskId) {
|
|
881
|
+
const record = records.get(cleanDeskId(deskId));
|
|
882
|
+
if (!record)
|
|
883
|
+
return;
|
|
884
|
+
await teardown(record);
|
|
885
|
+
}
|
|
886
|
+
async function destroy(deskId) {
|
|
887
|
+
const id = cleanDeskId(deskId);
|
|
888
|
+
const record = records.get(id);
|
|
889
|
+
if (!record)
|
|
890
|
+
return;
|
|
891
|
+
await teardown(record);
|
|
892
|
+
records.delete(id);
|
|
893
|
+
}
|
|
894
|
+
async function sweep() {
|
|
895
|
+
const timestamp = now();
|
|
896
|
+
let suspendedCount = 0;
|
|
897
|
+
for (const record of [...records.values()]) {
|
|
898
|
+
if (record.status !== 'resident')
|
|
899
|
+
continue;
|
|
900
|
+
const handover = record.screen?.handover;
|
|
901
|
+
if (handover && handover.deadline <= timestamp) {
|
|
902
|
+
await endHandover(record, 'expired');
|
|
903
|
+
}
|
|
904
|
+
if (record.leaseDeadline <= timestamp
|
|
905
|
+
|| timestamp - record.lastActivityAt >= idleSuspendMs) {
|
|
906
|
+
await teardown(record);
|
|
907
|
+
suspendedCount += 1;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
return suspendedCount;
|
|
911
|
+
}
|
|
912
|
+
function stats() {
|
|
913
|
+
let suspended = 0;
|
|
914
|
+
for (const record of records.values()) {
|
|
915
|
+
if (record.status === 'suspended')
|
|
916
|
+
suspended += 1;
|
|
917
|
+
}
|
|
918
|
+
return {
|
|
919
|
+
resident: occupied,
|
|
920
|
+
queued: queue.length,
|
|
921
|
+
capacity,
|
|
922
|
+
suspended,
|
|
923
|
+
lastStartedAt,
|
|
924
|
+
lastSuspendedAt,
|
|
925
|
+
reconnects,
|
|
926
|
+
lastReconnectAt,
|
|
927
|
+
lastReconnectDeskId,
|
|
928
|
+
lastError,
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
return { start, resume, suspend, destroy, stats, sweep };
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Boot-time probe. Distinguishes a host that is unusable — wrong platform,
|
|
935
|
+
* missing VMM, missing images — which throws, from capabilities that are
|
|
936
|
+
* merely absent, which are reported as booleans: `kvm` (no boot possible
|
|
937
|
+
* without it), `vsock` (the guest agent answered), and `virtioGpu` (a screen
|
|
938
|
+
* would have hardware-backed rendering). It boots a throwaway microVM through
|
|
939
|
+
* the backend, asks it two questions, and discards it.
|
|
940
|
+
*/
|
|
941
|
+
export async function verifyDeskHost(options) {
|
|
942
|
+
const platform = options.platform ?? process.platform;
|
|
943
|
+
const pathExists = options.pathExists ?? existsSync;
|
|
944
|
+
const deviceExists = options.deviceExists ?? existsSync;
|
|
945
|
+
const idFactory = options.idFactory ?? randomUUID;
|
|
946
|
+
const backend = options.backend ?? cloudHypervisorBackend;
|
|
947
|
+
const vmmPath = options.vmmPath ?? DEFAULT_VMM_PATH;
|
|
948
|
+
const kvmPath = options.kvmPath ?? DEFAULT_KVM_PATH;
|
|
949
|
+
if (platform !== 'linux') {
|
|
950
|
+
throw new DeskError(`The AppKit desk requires Linux; received ${platform}.`);
|
|
951
|
+
}
|
|
952
|
+
if (!pathExists(vmmPath)) {
|
|
953
|
+
throw new DeskError(`Cloud Hypervisor was not found at ${vmmPath}.`);
|
|
954
|
+
}
|
|
955
|
+
if (!pathExists(options.kernelPath)) {
|
|
956
|
+
throw new DeskError(`Guest kernel does not exist: ${options.kernelPath}`);
|
|
957
|
+
}
|
|
958
|
+
if (!pathExists(options.baseImagePath)) {
|
|
959
|
+
throw new DeskError(`Base image does not exist: ${options.baseImagePath}`);
|
|
960
|
+
}
|
|
961
|
+
if (!deviceExists(kvmPath)) {
|
|
962
|
+
return { supported: false, vmmPath, kvm: false, vsock: false, virtioGpu: false };
|
|
963
|
+
}
|
|
964
|
+
const scratchDir = options.scratchDir ?? tmpdir();
|
|
965
|
+
const deskId = `verify-${idFactory()}`;
|
|
966
|
+
const overlayPath = join(scratchDir, `${deskId}.raw`);
|
|
967
|
+
const plan = buildDeskLaunchPlan({
|
|
968
|
+
deskId,
|
|
969
|
+
vmmPath,
|
|
970
|
+
kvmPath,
|
|
971
|
+
kernelPath: options.kernelPath,
|
|
972
|
+
...(options.initramfsPath === undefined ? {} : { initramfsPath: options.initramfsPath }),
|
|
973
|
+
...(options.kernelCmdline === undefined ? {} : { kernelCmdline: options.kernelCmdline }),
|
|
974
|
+
baseImagePath: options.baseImagePath,
|
|
975
|
+
overlayPath,
|
|
976
|
+
vsockCid: 3,
|
|
977
|
+
runtimeDir: options.runtimeDir,
|
|
978
|
+
launcherIdentity: options.launcherIdentity,
|
|
979
|
+
}, { pathExists, deviceExists });
|
|
980
|
+
let machine;
|
|
981
|
+
try {
|
|
982
|
+
machine = await backend.boot(plan);
|
|
983
|
+
}
|
|
984
|
+
catch (error) {
|
|
985
|
+
throw new DeskError(`Desk verification could not boot a probe VM: ${errorMessage(error)}`);
|
|
986
|
+
}
|
|
987
|
+
let vsock = false;
|
|
988
|
+
let virtioGpu = false;
|
|
989
|
+
try {
|
|
990
|
+
try {
|
|
991
|
+
await machine.request({ op: 'ping' });
|
|
992
|
+
vsock = true;
|
|
993
|
+
}
|
|
994
|
+
catch {
|
|
995
|
+
vsock = false;
|
|
996
|
+
}
|
|
997
|
+
if (vsock) {
|
|
998
|
+
try {
|
|
999
|
+
virtioGpu = parseCapabilities(await machine.request({ op: 'capabilities' })).virtioGpu;
|
|
1000
|
+
}
|
|
1001
|
+
catch {
|
|
1002
|
+
virtioGpu = false;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
finally {
|
|
1007
|
+
try {
|
|
1008
|
+
await machine.shutdown();
|
|
1009
|
+
}
|
|
1010
|
+
catch {
|
|
1011
|
+
// The probe VM is discarded regardless of how cleanly it stops.
|
|
1012
|
+
}
|
|
1013
|
+
await rm(overlayPath, { force: true });
|
|
1014
|
+
}
|
|
1015
|
+
return { supported: vsock, vmmPath, kvm: true, vsock, virtioGpu };
|
|
1016
|
+
}
|
|
1017
|
+
function nonEmptyString(value, field) {
|
|
1018
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
1019
|
+
throw new DeskError(`${field} must be a non-empty string.`);
|
|
1020
|
+
}
|
|
1021
|
+
return value;
|
|
1022
|
+
}
|
|
1023
|
+
function requireStringValue(value, field) {
|
|
1024
|
+
if (typeof value !== 'string') {
|
|
1025
|
+
throw new DeskError(`${field} must be a string.`);
|
|
1026
|
+
}
|
|
1027
|
+
return value;
|
|
1028
|
+
}
|
|
1029
|
+
function positiveInteger(value, field) {
|
|
1030
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
1031
|
+
throw new DeskError(`${field} must be a positive integer.`);
|
|
1032
|
+
}
|
|
1033
|
+
return value;
|
|
1034
|
+
}
|
|
1035
|
+
function errorMessage(error) {
|
|
1036
|
+
return error instanceof Error ? error.message : String(error);
|
|
1037
|
+
}
|
|
1038
|
+
//# sourceMappingURL=host.js.map
|