@frockbot/plugin-fly-sprite 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.
@@ -0,0 +1,1394 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ ComputerError,
4
+ decodeComputerDoctorReportV1,
5
+ type ComputerControlRequestV1,
6
+ type ComputerDoctorReportV1,
7
+ type ComputerOperationOptions,
8
+ } from "@frockbot/computer-core";
9
+ import type {
10
+ ComputerHostControlResultV1,
11
+ ComputerHostFileReadResultV1,
12
+ ComputerHostOpenResultV1,
13
+ ComputerHostProvisioningV1,
14
+ ComputerHostViewerResultV1,
15
+ } from "@frockbot/computer-host-protocol";
16
+ import {
17
+ BIN_ROOT,
18
+ BOTS_ROOT,
19
+ BOUNDED_LOG_SCRIPT,
20
+ CONTROL_SCRIPT,
21
+ DATA_ROOT,
22
+ DESKTOP_GUI_LEASE_KEY,
23
+ DOCTOR_MARKER,
24
+ DOCTOR_SCRIPT,
25
+ HOME_ROOT,
26
+ LEASE_MAX_AGE_SECONDS,
27
+ NO_SLOTS_MARKER,
28
+ RUNTIME_ROOT,
29
+ SANCTIONED_SURFACE_ENV,
30
+ SCRATCH_ENV,
31
+ SCRATCH_ROOT,
32
+ shellQuote,
33
+ SHIMS_ROOT,
34
+ SLOT_IDLE_SECONDS,
35
+ WORKSPACE_SYNC_SERVICE,
36
+ WORKSPACES_ROOT,
37
+ } from "@frockbot/computer-host-runtime";
38
+ import type {
39
+ ComputerHostCallOptions,
40
+ ComputerHostExecCommandV1,
41
+ ComputerHostExecOutcomeV1,
42
+ } from "./host-client.js";
43
+
44
+ // The Computer's on-Sprite layout, its provisioning script, and its declared
45
+ // services live in `@frockbot/computer-host-runtime`, so the shared Computer
46
+ // host of ADR 0004 and this provider ship one runtime rather than two. Both
47
+ // names below are re-exported because they are part of this module's public
48
+ // surface: the sync Package names the watcher service, and the slot-reclaim
49
+ // threshold is policy a caller may need to reason about.
50
+ export { SLOT_IDLE_SECONDS, WORKSPACE_SYNC_SERVICE };
51
+
52
+ const MAX_OUTPUT = 30_000;
53
+ const MAX_STORAGE_OUTPUT = 500_000;
54
+ const EXEC_EXIT_MARKER = "__FROCKBOT_EXIT__";
55
+ /** Largest screenshot this provider will carry back off a Computer. */
56
+ export const SCREENSHOT_MAX_BYTES = 8 * 1024 * 1024;
57
+ /** Log bytes a single read carries back from a background process. */
58
+ export const PROCESS_LOG_DEFAULT_TAIL_BYTES = 8_192;
59
+ export const PROCESS_LOG_MAX_TAIL_BYTES = 64_000;
60
+ /** How long a stopped process is given to handle TERM before KILL. */
61
+ export const PROCESS_STOP_GRACE_SECONDS = 5;
62
+ const PROCESS_MARKER = "__FROCKBOT_PROCESS__";
63
+
64
+ /** Deadlines this provider asks the host for, per phase. */
65
+ const TIMEOUTS = {
66
+ /** Provisioning apt-installs a desktop stack on a cold Computer. */
67
+ open: 10 * 60_000,
68
+ command: 120_000,
69
+ browser: 45_000,
70
+ screenshot: 30_000,
71
+ /** box-doctor probes a dozen things, none of them slow. */
72
+ doctor: 45_000,
73
+ control: 15_000,
74
+ viewer: 30_000,
75
+ } as const;
76
+
77
+ /**
78
+ * The shared Computer host as this provider uses it (ADR 0004).
79
+ *
80
+ * `ComputerHostClient` satisfies it, and so does a test double. It is declared
81
+ * here rather than imported as a class so this module depends on the *shape*
82
+ * of the host and not on the transport: the client owns the service binding,
83
+ * the framing, and the retry classification, and this module owns what a Bot
84
+ * tenant means on a Computer.
85
+ */
86
+ export interface ComputerHostSurfaceV1 {
87
+ open(options?: ComputerHostCallOptions): Promise<ComputerHostOpenResultV1>;
88
+ exec(
89
+ command: ComputerHostExecCommandV1,
90
+ options?: ComputerHostCallOptions,
91
+ ): Promise<ComputerHostExecOutcomeV1>;
92
+ fileRead(
93
+ path: string,
94
+ options?: ComputerHostCallOptions,
95
+ ): Promise<ComputerHostFileReadResultV1>;
96
+ control(
97
+ action: "acquire" | "renew" | "release",
98
+ ownerId: string,
99
+ maxAgeSeconds: number,
100
+ options?: ComputerHostCallOptions & { scope?: "bot" | "desktop-gui" },
101
+ ): Promise<ComputerHostControlResultV1>;
102
+ viewer(
103
+ action: "open" | "renew" | "revoke",
104
+ options?: ComputerHostCallOptions & { sessionId?: string },
105
+ ): Promise<ComputerHostViewerResultV1>;
106
+ }
107
+
108
+ /**
109
+ * Makes the host surface for one Bot on one User's Computer.
110
+ *
111
+ * The identity and the tenant are both arguments because they mean different
112
+ * things (ADR 0012): the User names the Computer, the Bot names the tenant on
113
+ * it, and the host has to be told both on every call.
114
+ */
115
+ export type ComputerHostFactoryV1 = (
116
+ identity: { userId: string },
117
+ tenant: { botId: string },
118
+ ) => ComputerHostSurfaceV1;
119
+
120
+ export interface ComputerBotIdentity {
121
+ id: string;
122
+ name?: string;
123
+ description?: string;
124
+ }
125
+
126
+ interface AgentLayout {
127
+ identity: ComputerBotIdentity;
128
+ key: string;
129
+ runtimeDir: string;
130
+ workspaceDir: string;
131
+ }
132
+
133
+ /** One capture of a tenant's desktop, as it left the Computer. */
134
+ export interface SpriteScreenshotV1 {
135
+ bytes: Uint8Array;
136
+ /** The X display the capture was taken from, e.g. `:100`. */
137
+ display: string;
138
+ /** Where the capture sat on the Computer before it was read back. */
139
+ path: string;
140
+ capturedAt: string;
141
+ }
142
+
143
+ /** What a background launch left on the Computer. */
144
+ export interface SpriteProcessLaunchV1 {
145
+ pid: number;
146
+ logPath: string;
147
+ cwd: string;
148
+ }
149
+
150
+ /** What the Computer says about one background process right now. */
151
+ export interface SpriteProcessStateV1 {
152
+ alive: boolean;
153
+ exitCode?: number;
154
+ logTail: string;
155
+ }
156
+
157
+ export interface SpriteAgentExecResult {
158
+ exitCode: number | null;
159
+ stdout: string;
160
+ stderr: string;
161
+ outputTruncated: boolean;
162
+ }
163
+
164
+ export interface FlySpriteComputerOptions {
165
+ /** Whose Computer this is. One Computer per User (ADR 0012). */
166
+ identity?: { userId: string };
167
+ /**
168
+ * The shared Computer host. Absent, and this Computer is unconfigured: the
169
+ * provider Package can no longer reach a Sprite from the Durable Object on
170
+ * its own, so a Computer with no host is a Computer with no compute.
171
+ */
172
+ host?: ComputerHostFactoryV1;
173
+ /** The Sprite name this Computer expects, before the host answers with one. */
174
+ spriteName?: string;
175
+ /**
176
+ * The owner guarded commands name. A `computerUse` child receives the task
177
+ * owner that already holds `desktop-gui`, so its own commands pass the same
178
+ * fence that refuses every other Bot and human session.
179
+ */
180
+ agentControlOwnerId?: string;
181
+ respectHumanControl?: boolean;
182
+ }
183
+
184
+ export interface BrowserAction {
185
+ action: "snapshot" | "navigate" | "click" | "fill" | "press" | "wait";
186
+ url?: string;
187
+ role?: string;
188
+ name?: string;
189
+ label?: string;
190
+ text?: string;
191
+ key?: string;
192
+ exact?: boolean;
193
+ milliseconds?: number;
194
+ }
195
+
196
+ export interface ComputerConnection {
197
+ botId: string;
198
+ botKey: string;
199
+ spriteName: string;
200
+ viewerUrl: string;
201
+ viewerSessionId: string;
202
+ viewerExpiresAt?: string;
203
+ /** The tenant's X display on the shared Computer, e.g. `:100`. */
204
+ display: string;
205
+ /** The tenant's durable directory, relative to the Workspace home. */
206
+ directory: string;
207
+ /** The progress from the host wake that opened this connection, if any. */
208
+ message?: string;
209
+ }
210
+
211
+ function provisioningMessage(progress: ComputerHostProvisioningV1): string {
212
+ return progress.kind === "update"
213
+ ? `Updating the Computer: ${progress.label}`
214
+ : `Preparing the Computer: ${progress.label}`;
215
+ }
216
+
217
+ function configuredName(): string {
218
+ const name = process.env.FROCKBOT_SPRITE_NAME?.trim() || "frockbot-barebones";
219
+ if (!/^[a-z][a-z0-9-]{2,62}$/.test(name)) {
220
+ throw new Error(
221
+ "FROCKBOT_SPRITE_NAME must be 3-63 lowercase letters, numbers, or hyphens",
222
+ );
223
+ }
224
+ return name;
225
+ }
226
+
227
+ export function flySpriteNameForBot(
228
+ botId: string,
229
+ baseName = configuredName(),
230
+ ): string {
231
+ const normalizedBase = baseName.trim();
232
+ if (!/^[a-z][a-z0-9-]{2,62}$/.test(normalizedBase)) {
233
+ throw new Error(
234
+ "Fly Sprite base name must be 3-63 lowercase letters, numbers, or hyphens",
235
+ );
236
+ }
237
+ const suffix = createHash("sha256").update(botId).digest("hex").slice(0, 12);
238
+ const prefix = normalizedBase.slice(0, 49).replace(/-+$/g, "");
239
+ return `${prefix}-${suffix}`;
240
+ }
241
+
242
+ function normalizedIdentity(
243
+ input: string | ComputerBotIdentity,
244
+ ): ComputerBotIdentity {
245
+ const identity = typeof input === "string" ? { id: input } : input;
246
+ const id = identity.id.trim();
247
+ if (!id || id.length > 200) {
248
+ throw new Error("Computer Bot id must contain 1-200 characters");
249
+ }
250
+ return {
251
+ id,
252
+ name: identity.name?.trim() || undefined,
253
+ description: identity.description?.trim() || undefined,
254
+ };
255
+ }
256
+
257
+ export function computerBotKey(botId: string): string {
258
+ const id = normalizedIdentity(botId).id;
259
+ const slug = id
260
+ .normalize("NFKD")
261
+ .toLowerCase()
262
+ .replace(/[^a-z0-9]+/g, "-")
263
+ .replace(/^-+|-+$/g, "")
264
+ .slice(0, 28);
265
+ const digest = createHash("sha256").update(id).digest("hex").slice(0, 12);
266
+ return `${slug || "bot"}-${digest}`;
267
+ }
268
+
269
+ function layoutFor(input: string | ComputerBotIdentity): AgentLayout {
270
+ const identity = normalizedIdentity(input);
271
+ const key = computerBotKey(identity.id);
272
+ return {
273
+ identity,
274
+ key,
275
+ runtimeDir: `${BOTS_ROOT}/${key}`,
276
+ workspaceDir: `${WORKSPACES_ROOT}/${key}`,
277
+ };
278
+ }
279
+
280
+ function errorText(error: unknown): string {
281
+ return error instanceof Error ? error.message : String(error);
282
+ }
283
+
284
+ const decoder = new TextDecoder();
285
+
286
+ function outputText(bytes: Uint8Array): string {
287
+ return decoder.decode(bytes);
288
+ }
289
+
290
+ function clipped(text: string, limit = MAX_OUTPUT): string {
291
+ if (text.length <= limit) return text;
292
+ return `${text.slice(0, limit)}\n… output truncated`;
293
+ }
294
+
295
+ /** True when the host refused because no desktop slot was free. */
296
+ function isSlotExhaustion(error: unknown): boolean {
297
+ const text = errorText(error);
298
+ return (
299
+ text.includes(NO_SLOTS_MARKER) ||
300
+ text.includes("no desktop slots available") ||
301
+ text.includes("no display until one is idle")
302
+ );
303
+ }
304
+
305
+ export class FlySpriteAgentComputer {
306
+ readonly botId: string;
307
+ readonly botKey: string;
308
+ private readonly computer: FlySpriteComputer;
309
+ private readonly layout: AgentLayout;
310
+
311
+ constructor(computer: FlySpriteComputer, layout: AgentLayout) {
312
+ this.computer = computer;
313
+ this.layout = layout;
314
+ this.botId = layout.identity.id;
315
+ this.botKey = layout.key;
316
+ }
317
+
318
+ /** The tenant's allocated X display, once its desktop has been ensured. */
319
+ get display(): string | undefined {
320
+ return this.computer.displayForTenant(this.layout.key);
321
+ }
322
+
323
+ /** The tenant's durable directory, relative to the Workspace home. */
324
+ get directory(): string {
325
+ return `agent-data/agents/${this.layout.key}`;
326
+ }
327
+
328
+ ensure(signal?: AbortSignal): Promise<ComputerConnection> {
329
+ return this.computer.ensureAgent(this.layout, signal);
330
+ }
331
+
332
+ connect(options?: ComputerOperationOptions): Promise<ComputerConnection> {
333
+ return this.computer.connectAgent(this.layout, options);
334
+ }
335
+
336
+ run(command: string, signal: AbortSignal): Promise<string> {
337
+ return this.computer.runForAgent(this.layout, command, signal);
338
+ }
339
+
340
+ exec(
341
+ command: string,
342
+ signal: AbortSignal,
343
+ limits: { timeoutMs?: number; maxOutputBytes?: number } = {},
344
+ ): Promise<SpriteAgentExecResult> {
345
+ return this.computer.execForAgent(this.layout, command, signal, limits);
346
+ }
347
+
348
+ runStorage(command: string, signal: AbortSignal): Promise<string> {
349
+ return this.computer.runStorageForAgent(this.layout, command, signal);
350
+ }
351
+
352
+ browser(action: BrowserAction, signal: AbortSignal): Promise<string> {
353
+ return this.computer.browserForAgent(this.layout, action, signal);
354
+ }
355
+
356
+ /** Captures this tenant's own desktop. */
357
+ screenshot(signal: AbortSignal): Promise<SpriteScreenshotV1> {
358
+ return this.computer.screenshotForAgent(this.layout, signal);
359
+ }
360
+
361
+ /** Runs the Computer's self-check for this tenant. */
362
+ doctor(signal: AbortSignal): Promise<ComputerDoctorReportV1> {
363
+ return this.computer.doctorForAgent(this.layout, signal);
364
+ }
365
+
366
+ launchProcess(
367
+ processId: string,
368
+ command: string,
369
+ signal: AbortSignal,
370
+ ): Promise<SpriteProcessLaunchV1> {
371
+ return this.computer.launchProcessForAgent(
372
+ this.layout,
373
+ processId,
374
+ command,
375
+ signal,
376
+ );
377
+ }
378
+
379
+ inspectProcess(
380
+ processId: string,
381
+ signal: AbortSignal,
382
+ tailBytes?: number,
383
+ ): Promise<SpriteProcessStateV1> {
384
+ return this.computer.inspectProcessForAgent(
385
+ this.layout,
386
+ processId,
387
+ signal,
388
+ tailBytes,
389
+ );
390
+ }
391
+
392
+ stopProcess(
393
+ processId: string,
394
+ signal: AbortSignal,
395
+ ): Promise<SpriteProcessStateV1> {
396
+ return this.computer.stopProcessForAgent(this.layout, processId, signal);
397
+ }
398
+
399
+ /** The Computer's provisioning generation, once it has been opened. */
400
+ get generation(): number | undefined {
401
+ return this.computer.generationForTenant(this.layout.key);
402
+ }
403
+
404
+ /** The generation the Computer is on now, asked of the host. */
405
+ currentGeneration(signal?: AbortSignal): Promise<number> {
406
+ return this.computer.currentGenerationForAgent(this.layout, signal);
407
+ }
408
+
409
+ /** This tenant's working directory on the Computer. */
410
+ get workingDirectory(): string {
411
+ return this.layout.workspaceDir;
412
+ }
413
+
414
+ /** Opens a viewer session on this tenant's desktop. */
415
+ viewer(
416
+ options?: ComputerOperationOptions,
417
+ ): Promise<ComputerHostViewerResultV1> {
418
+ return this.computer.viewerForAgent(
419
+ this.layout,
420
+ "open",
421
+ options?.signal,
422
+ undefined,
423
+ options?.effectId,
424
+ );
425
+ }
426
+
427
+ revokeViewer(
428
+ sessionId: string,
429
+ options?: ComputerOperationOptions,
430
+ ): Promise<ComputerHostViewerResultV1> {
431
+ return this.computer.viewerForAgent(
432
+ this.layout,
433
+ "revoke",
434
+ options?.signal,
435
+ sessionId,
436
+ options?.effectId,
437
+ );
438
+ }
439
+
440
+ refreshViewer(
441
+ sessionId: string,
442
+ options?: ComputerOperationOptions,
443
+ ): Promise<ComputerHostViewerResultV1> {
444
+ return this.computer.viewerForAgent(
445
+ this.layout,
446
+ "renew",
447
+ options?.signal,
448
+ sessionId,
449
+ options?.effectId,
450
+ );
451
+ }
452
+
453
+ takeControl(
454
+ options?: ComputerOperationOptions,
455
+ request?: ComputerControlRequestV1,
456
+ ): Promise<ComputerHostControlResultV1> {
457
+ return this.computer.control(
458
+ this.layout,
459
+ "acquire",
460
+ options?.signal,
461
+ request,
462
+ options?.effectId,
463
+ );
464
+ }
465
+
466
+ refreshControl(
467
+ options?: ComputerOperationOptions,
468
+ request?: ComputerControlRequestV1,
469
+ ): Promise<ComputerHostControlResultV1> {
470
+ return this.computer.control(
471
+ this.layout,
472
+ "renew",
473
+ options?.signal,
474
+ request,
475
+ options?.effectId,
476
+ );
477
+ }
478
+
479
+ releaseControl(
480
+ options?: ComputerOperationOptions,
481
+ request?: ComputerControlRequestV1,
482
+ ): Promise<void> {
483
+ return this.computer.releaseForAgent(
484
+ this.layout,
485
+ options?.signal,
486
+ request,
487
+ options?.effectId,
488
+ );
489
+ }
490
+
491
+ /** The human session owner this Computer uses for local takeover. */
492
+ get controlOwnerId(): string {
493
+ return this.computer.humanControlOwnerId;
494
+ }
495
+ }
496
+
497
+ /**
498
+ * One User's Computer, driven through the shared host.
499
+ *
500
+ * Everything Fly-specific that used to live here — the Sprites SDK, the
501
+ * provisioning script, the declared services, the viewer token files — is on
502
+ * the host now (ADR 0004). What remains is what a Bot *tenant* means on a
503
+ * Computer: its directory key, its human-control guard, and the shape of the
504
+ * commands it runs. That is why `FlySpriteAgentComputer`'s method surface is
505
+ * unchanged: `workspace.ts` and `sync.ts` generate bash against it and neither
506
+ * knows, or needs to know, that the bash now travels on a command's stdin.
507
+ */
508
+ export class FlySpriteComputer {
509
+ readonly configured: boolean;
510
+ /** The caller identity guarded Bot commands name on the Computer. */
511
+ readonly ownerId: string;
512
+ /** The local viewer session, kept distinct from the guarded Bot caller. */
513
+ readonly humanControlOwnerId = `human:${randomUUID()}`;
514
+ private readonly identity: { userId: string };
515
+ private readonly host?: ComputerHostFactoryV1;
516
+ private readonly respectHumanControl: boolean;
517
+ private expectedSpriteName: string;
518
+ private readonly surfaces = new Map<string, ComputerHostSurfaceV1>();
519
+ private readonly agentPromises = new Map<
520
+ string,
521
+ Promise<ComputerConnection>
522
+ >();
523
+ private readonly storagePromises = new Map<string, Promise<unknown>>();
524
+ private readonly displays = new Map<string, string>();
525
+ private readonly generations = new Map<string, number>();
526
+
527
+ constructor(options: FlySpriteComputerOptions = {}) {
528
+ this.identity = options.identity ?? {
529
+ userId: process.env.FROCKBOT_USER_ID?.trim() || "local-user",
530
+ };
531
+ this.host = options.host;
532
+ this.expectedSpriteName = options.spriteName ?? configuredName();
533
+ this.ownerId = options.agentControlOwnerId ?? `agent:${randomUUID()}`;
534
+ this.configured = Boolean(options.host);
535
+ this.respectHumanControl = options.respectHumanControl ?? true;
536
+ }
537
+
538
+ /**
539
+ * The Sprite backing this Computer. It is the host's answer once one has
540
+ * been opened, and the configured expectation before that: the host derives
541
+ * the name from the User, so the two agree, and only the host's is a fact.
542
+ */
543
+ get spriteName(): string {
544
+ return this.expectedSpriteName;
545
+ }
546
+
547
+ bot(identity: string | ComputerBotIdentity): FlySpriteAgentComputer {
548
+ return new FlySpriteAgentComputer(this, layoutFor(identity));
549
+ }
550
+
551
+ /**
552
+ * The X display this Computer allocated to one tenant, once its desktop has
553
+ * been ensured. Slots are allocated on demand, exactly as GrokBot allocates
554
+ * displays on demand rather than one per agent, so this is `undefined` until
555
+ * the tenant's desktop has started.
556
+ */
557
+ displayForTenant(botKey: string): string | undefined {
558
+ return this.displays.get(botKey);
559
+ }
560
+
561
+ /** The Computer's provisioning generation, as the host last reported it. */
562
+ generationForTenant(botKey: string): number | undefined {
563
+ return this.generations.get(botKey);
564
+ }
565
+
566
+ async ensureAgent(
567
+ layout: AgentLayout,
568
+ signal?: AbortSignal,
569
+ ): Promise<ComputerConnection> {
570
+ this.hostFor(layout);
571
+ let promise = this.agentPromises.get(layout.key);
572
+ if (!promise) {
573
+ promise = this.openAgent(layout, { signal }).catch((error: unknown) => {
574
+ this.agentPromises.delete(layout.key);
575
+ throw error;
576
+ });
577
+ this.agentPromises.set(layout.key, promise);
578
+ }
579
+ return promise;
580
+ }
581
+
582
+ connectAgent(
583
+ layout: AgentLayout,
584
+ options?: ComputerOperationOptions,
585
+ ): Promise<ComputerConnection> {
586
+ this.hostFor(layout);
587
+ return this.openAgent(layout, options);
588
+ }
589
+
590
+ async runForAgent(
591
+ layout: AgentLayout,
592
+ command: string,
593
+ signal: AbortSignal,
594
+ ): Promise<string> {
595
+ const host = await this.readyHost(layout, signal);
596
+ const script = [
597
+ this.agentControlGuard(layout),
598
+ ...this.tenantEnvironment(layout),
599
+ command,
600
+ ].join("\n");
601
+ const outcome = await this.execute(
602
+ host,
603
+ script,
604
+ { signal, timeoutMs: TIMEOUTS.command, maxOutputBytes: MAX_OUTPUT * 2 },
605
+ "Sprite command failed",
606
+ );
607
+ return clipped(
608
+ [outputText(outcome.stdout), outputText(outcome.stderr)]
609
+ .filter(Boolean)
610
+ .join("\n"),
611
+ );
612
+ }
613
+
614
+ async execForAgent(
615
+ layout: AgentLayout,
616
+ command: string,
617
+ signal: AbortSignal,
618
+ limits: { timeoutMs?: number; maxOutputBytes?: number } = {},
619
+ ): Promise<SpriteAgentExecResult> {
620
+ const host = await this.readyHost(layout, signal);
621
+ const maxOutput = Math.max(
622
+ 1,
623
+ Math.min(limits.maxOutputBytes ?? MAX_OUTPUT, MAX_OUTPUT),
624
+ );
625
+ // The marker survives the move to the host for one reason: the outer
626
+ // script's exit code belongs to the control guard, and the Bot's command
627
+ // has an exit code of its own. Collapsing the two would make a Computer
628
+ // under human control indistinguishable from a command that failed.
629
+ const script = [
630
+ this.agentControlGuard(layout),
631
+ ...this.tenantEnvironment(layout),
632
+ `bash -c ${shellQuote(command)}`,
633
+ `printf '\\n%s%s\\n' ${shellQuote(EXEC_EXIT_MARKER)} "$?"`,
634
+ ].join("\n");
635
+ const outcome = await this.execute(
636
+ host,
637
+ script,
638
+ {
639
+ signal,
640
+ timeoutMs: Math.max(
641
+ 1,
642
+ Math.min(limits.timeoutMs ?? TIMEOUTS.command, TIMEOUTS.command),
643
+ ),
644
+ maxOutputBytes: MAX_OUTPUT * 2,
645
+ },
646
+ "Sprite command failed",
647
+ );
648
+ const raw = outputText(outcome.stdout);
649
+ const match = new RegExp(`\\n?${EXEC_EXIT_MARKER}(\\d+)\\n?$`).exec(raw);
650
+ const stdout = match ? raw.slice(0, match.index) : raw;
651
+ const stderr = outputText(outcome.stderr);
652
+ return {
653
+ exitCode: match ? Number(match[1]) : null,
654
+ stdout: stdout.slice(0, maxOutput),
655
+ stderr: stderr.slice(0, maxOutput),
656
+ outputTruncated:
657
+ !match ||
658
+ outcome.outputTruncated ||
659
+ stdout.length > maxOutput ||
660
+ stderr.length > maxOutput,
661
+ };
662
+ }
663
+
664
+ /**
665
+ * The Workspace and the durable-root sync's own commands.
666
+ *
667
+ * They carry no human-control guard on purpose: reconciling durable state is
668
+ * not the Bot acting on the Computer, and a human holding the screen must
669
+ * not stop a Turn's files from reaching object storage.
670
+ */
671
+ async runStorageForAgent(
672
+ layout: AgentLayout,
673
+ command: string,
674
+ signal: AbortSignal,
675
+ ): Promise<string> {
676
+ const host = this.hostFor(layout);
677
+ signal.throwIfAborted();
678
+ await this.readyStorage(layout, host, signal);
679
+ const script = [...this.tenantEnvironment(layout), command].join("\n");
680
+ const outcome = await this.execute(
681
+ host,
682
+ script,
683
+ {
684
+ signal,
685
+ timeoutMs: TIMEOUTS.command,
686
+ maxOutputBytes: MAX_STORAGE_OUTPUT * 2,
687
+ },
688
+ "Sprite storage operation failed",
689
+ );
690
+ const stdout = outputText(outcome.stdout);
691
+ if (stdout.length > MAX_STORAGE_OUTPUT || outcome.outputTruncated) {
692
+ throw new ComputerError(
693
+ "limit-exceeded",
694
+ "Sprite storage output exceeded the maximum size",
695
+ );
696
+ }
697
+ return stdout;
698
+ }
699
+
700
+ /**
701
+ * Captures the tenant's own desktop and carries the PNG back.
702
+ *
703
+ * Two host operations and no new one: a guarded `exec` runs `scrot` under
704
+ * the tenant's own `DISPLAY`, and `file/read` brings the bytes back. The
705
+ * guard is the same one every Bot command carries, so a screenshot taken
706
+ * while a human holds the takeover lease is refused rather than handing the
707
+ * Bot a picture of the human's session.
708
+ *
709
+ * The file is read back rather than left where it landed because a durable
710
+ * root reached by a shell write syncs back `unattributed`: the caller writes
711
+ * these bytes through the Workspace, which is what records the Bot as their
712
+ * writer.
713
+ */
714
+ async screenshotForAgent(
715
+ layout: AgentLayout,
716
+ signal: AbortSignal,
717
+ ): Promise<SpriteScreenshotV1> {
718
+ const host = await this.readyHost(layout, signal);
719
+ const display = this.displays.get(layout.key);
720
+ if (!display) {
721
+ throw new ComputerError(
722
+ "capability-unavailable",
723
+ `Bot "${layout.identity.id}" has no desktop on this Computer to capture`,
724
+ );
725
+ }
726
+ const bot = `${BOTS_ROOT}/${layout.key}`;
727
+ const path = `${bot}/screenshot.png`;
728
+ const script = [
729
+ this.agentControlGuard(layout),
730
+ ...this.tenantEnvironment(layout),
731
+ `export DISPLAY=${shellQuote(display)}`,
732
+ // `scrot` is one of the shimmed names, and this is the surface the shim
733
+ // exists to point at, so it is allowed through here and nowhere else.
734
+ `export ${SANCTIONED_SURFACE_ENV}=1`,
735
+ `rm -f ${shellQuote(path)}`,
736
+ `scrot --overwrite ${shellQuote(path)}`,
737
+ `stat -c %s ${shellQuote(path)}`,
738
+ ].join("\n");
739
+ const outcome = await this.execute(
740
+ host,
741
+ script,
742
+ {
743
+ signal,
744
+ timeoutMs: TIMEOUTS.screenshot,
745
+ maxOutputBytes: MAX_OUTPUT,
746
+ },
747
+ "Sprite screenshot failed",
748
+ );
749
+ const size = Number(outputText(outcome.stdout).trim().split("\n").pop());
750
+ if (!Number.isSafeInteger(size) || size <= 0) {
751
+ throw new ComputerError(
752
+ "provider-unavailable",
753
+ "The Computer produced no screenshot",
754
+ );
755
+ }
756
+ if (size > SCREENSHOT_MAX_BYTES) {
757
+ throw new ComputerError(
758
+ "limit-exceeded",
759
+ `The screenshot is ${size} bytes, past the ${SCREENSHOT_MAX_BYTES}-byte limit`,
760
+ );
761
+ }
762
+ const read = await host.fileRead(path, {
763
+ signal,
764
+ timeoutMs: TIMEOUTS.screenshot,
765
+ });
766
+ const bytes = Uint8Array.from(Buffer.from(read.bytesBase64, "base64"));
767
+ if (bytes.byteLength === 0) {
768
+ throw new ComputerError(
769
+ "provider-unavailable",
770
+ "The Computer returned an empty screenshot",
771
+ );
772
+ }
773
+ return {
774
+ bytes,
775
+ display,
776
+ path,
777
+ capturedAt: new Date().toISOString(),
778
+ };
779
+ }
780
+
781
+ /**
782
+ * Runs box-doctor and decodes its report.
783
+ *
784
+ * No human-control guard, and deliberately: a Computer a human has taken
785
+ * over is exactly a Computer somebody may need to ask what is wrong with,
786
+ * and every check reads. The tenant stamp still runs, because asking is
787
+ * using and a tenant being asked about must not lose its display slot
788
+ * mid-answer.
789
+ *
790
+ * The script prints its report on one marked line and its human-readable
791
+ * lines to `/tmp/box-doctor.log`, so the marker is what separates the report
792
+ * from anything else the Computer said.
793
+ */
794
+ async doctorForAgent(
795
+ layout: AgentLayout,
796
+ signal: AbortSignal,
797
+ ): Promise<ComputerDoctorReportV1> {
798
+ const host = await this.readyHost(layout, signal);
799
+ const generation = this.generations.get(layout.key) ?? 0;
800
+ const script = [
801
+ this.tenantStamp(layout),
802
+ ...this.tenantEnvironment(layout),
803
+ `if [ ! -x ${DOCTOR_SCRIPT} ]; then echo "missing" >&2; exit 69; fi`,
804
+ `${DOCTOR_SCRIPT} ${shellQuote(layout.key)} ${String(generation)}`,
805
+ ].join("\n");
806
+ let outcome: ComputerHostExecOutcomeV1;
807
+ try {
808
+ outcome = await this.execute(
809
+ host,
810
+ script,
811
+ {
812
+ signal,
813
+ timeoutMs: TIMEOUTS.doctor,
814
+ maxOutputBytes: MAX_OUTPUT * 2,
815
+ },
816
+ "Sprite self-check failed",
817
+ );
818
+ } catch (error) {
819
+ // A Computer provisioned before the self-check existed has no script to
820
+ // run. That is a stated outcome — it installs on the next open — not a
821
+ // crash, and saying which it is costs one sentence.
822
+ if (errorText(error).includes("missing")) {
823
+ throw new ComputerError(
824
+ "capability-unavailable",
825
+ "This Computer has no self-check installed yet; it is installed the next time the Computer is opened",
826
+ );
827
+ }
828
+ throw error;
829
+ }
830
+ const line = outputText(outcome.stdout)
831
+ .split("\n")
832
+ .find((candidate) => candidate.startsWith(DOCTOR_MARKER));
833
+ let parsed: unknown;
834
+ try {
835
+ parsed = line ? JSON.parse(line.slice(DOCTOR_MARKER.length)) : undefined;
836
+ } catch {
837
+ parsed = undefined;
838
+ }
839
+ const report = decodeComputerDoctorReportV1(parsed);
840
+ if (!report) {
841
+ throw new ComputerError(
842
+ "provider-failure",
843
+ "The Computer's self-check produced no readable report",
844
+ );
845
+ }
846
+ return report;
847
+ }
848
+
849
+ /**
850
+ * Starts a command that outlives its Turn.
851
+ *
852
+ * `setsid` makes the command a process group leader, which is what lets a
853
+ * later `stop` end the whole tree with one signal rather than orphaning the
854
+ * children of a shell pipeline. `nohup` detaches it from the exec's own
855
+ * terminal, so the process survives the connection closing — and connections
856
+ * to a Computer are expected to drop on every pause.
857
+ *
858
+ * The launch returns as soon as the pid file exists. Nothing here keeps the
859
+ * Computer awake: a process is a thing running on a Computer while it is
860
+ * awake, not a reason for it to stay that way.
861
+ */
862
+ async launchProcessForAgent(
863
+ layout: AgentLayout,
864
+ processId: string,
865
+ command: string,
866
+ signal: AbortSignal,
867
+ ): Promise<SpriteProcessLaunchV1> {
868
+ const host = await this.readyHost(layout, signal);
869
+ const directory = this.processDirectory(layout, processId);
870
+ const script = [
871
+ this.agentControlGuard(layout),
872
+ ...this.tenantEnvironment(layout),
873
+ `DIR=${shellQuote(directory)}`,
874
+ 'mkdir -p "$DIR"',
875
+ `printf %s ${shellQuote(command)} > "$DIR/command"`,
876
+ // One `bash -c` holding the pipeline, so `$!` is the group leader and
877
+ // the exit code recorded is the command's own, not the logger's.
878
+ `setsid nohup bash -c ${shellQuote(
879
+ [
880
+ `bash -c ${shellQuote(command)} 2>&1 | ${BOUNDED_LOG_SCRIPT} "${directory}/log"`,
881
+ `printf '%s\\n' "\${PIPESTATUS[0]}" > "${directory}/exit"`,
882
+ ].join("\n"),
883
+ )} >/dev/null 2>&1 &`,
884
+ `printf '%s\\n' "$!" > "$DIR/pid"`,
885
+ `printf '%s%s\\n' ${shellQuote(PROCESS_MARKER)} "$(cat "$DIR/pid")"`,
886
+ ].join("\n");
887
+ const outcome = await this.execute(
888
+ host,
889
+ script,
890
+ { signal, timeoutMs: TIMEOUTS.control, maxOutputBytes: MAX_OUTPUT },
891
+ "Sprite background launch failed",
892
+ );
893
+ const pid = Number(
894
+ new RegExp(`${PROCESS_MARKER}(\\d+)`).exec(
895
+ outputText(outcome.stdout),
896
+ )?.[1],
897
+ );
898
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
899
+ throw new ComputerError(
900
+ "provider-unavailable",
901
+ "The Computer started no background process",
902
+ );
903
+ }
904
+ return { pid, logPath: `${directory}/log`, cwd: layout.workspaceDir };
905
+ }
906
+
907
+ /**
908
+ * Reads a process's outcome. It never restarts anything: recovery reads an
909
+ * outcome rather than repeating an effect.
910
+ */
911
+ async inspectProcessForAgent(
912
+ layout: AgentLayout,
913
+ processId: string,
914
+ signal: AbortSignal,
915
+ tailBytes?: number,
916
+ ): Promise<SpriteProcessStateV1> {
917
+ const host = await this.readyHost(layout, signal);
918
+ return this.readProcess(host, layout, processId, signal, tailBytes);
919
+ }
920
+
921
+ /**
922
+ * Ends the process group: TERM, a grace, then KILL.
923
+ *
924
+ * The negative pid is the point — a background command is usually a shell
925
+ * pipeline, and signalling only the leader leaves its children running with
926
+ * nothing recording them.
927
+ */
928
+ async stopProcessForAgent(
929
+ layout: AgentLayout,
930
+ processId: string,
931
+ signal: AbortSignal,
932
+ ): Promise<SpriteProcessStateV1> {
933
+ const host = await this.readyHost(layout, signal);
934
+ const directory = this.processDirectory(layout, processId);
935
+ const script = [
936
+ this.agentControlGuard(layout),
937
+ ...this.tenantEnvironment(layout),
938
+ `DIR=${shellQuote(directory)}`,
939
+ 'PID=$(cat "$DIR/pid" 2>/dev/null || echo 0)',
940
+ 'if [ "$PID" -gt 0 ]; then',
941
+ ' kill -TERM -"$PID" 2>/dev/null || kill -TERM "$PID" 2>/dev/null || true',
942
+ ` for _ in $(seq 1 ${PROCESS_STOP_GRACE_SECONDS * 10}); do`,
943
+ ' kill -0 "$PID" 2>/dev/null || break',
944
+ " sleep 0.1",
945
+ " done",
946
+ ' if kill -0 "$PID" 2>/dev/null; then',
947
+ ' kill -KILL -"$PID" 2>/dev/null || kill -KILL "$PID" 2>/dev/null || true',
948
+ " fi",
949
+ "fi",
950
+ // A stop that had to signal leaves no exit file of its own, so one is
951
+ // written here: an ended process must not read back as `unknown`.
952
+ '[ -f "$DIR/exit" ] || printf \'143\\n\' > "$DIR/exit"',
953
+ ].join("\n");
954
+ await this.execute(
955
+ host,
956
+ script,
957
+ {
958
+ signal,
959
+ timeoutMs: (PROCESS_STOP_GRACE_SECONDS + 10) * 1_000,
960
+ maxOutputBytes: MAX_OUTPUT,
961
+ },
962
+ "Sprite background stop failed",
963
+ );
964
+ return this.readProcess(host, layout, processId, signal);
965
+ }
966
+
967
+ /**
968
+ * Asks the host what generation this Computer is on *now*.
969
+ *
970
+ * Deliberately not the cached answer from `ensureAgent`: the cached one is
971
+ * the generation this provider instance opened under, and the whole question
972
+ * a background process asks is whether the Computer answering today is the
973
+ * Computer it was launched on. A reprovision between the two is exactly the
974
+ * case that must not read back as `running`.
975
+ */
976
+ async currentGenerationForAgent(
977
+ layout: AgentLayout,
978
+ signal?: AbortSignal,
979
+ ): Promise<number> {
980
+ const host = await this.readyHost(layout, signal);
981
+ const opened = await host.open({ signal, timeoutMs: TIMEOUTS.open });
982
+ this.generations.set(layout.key, opened.generation);
983
+ if (opened.display) this.displays.set(layout.key, opened.display);
984
+ return opened.generation;
985
+ }
986
+
987
+ private processDirectory(layout: AgentLayout, processId: string): string {
988
+ return `${BOTS_ROOT}/${layout.key}/processes/${processId}`;
989
+ }
990
+
991
+ /**
992
+ * One read of a process's directory: liveness, exit code, and the bounded
993
+ * log the logger keeps in two halves.
994
+ */
995
+ private async readProcess(
996
+ host: ComputerHostSurfaceV1,
997
+ layout: AgentLayout,
998
+ processId: string,
999
+ signal: AbortSignal,
1000
+ tailBytes = PROCESS_LOG_DEFAULT_TAIL_BYTES,
1001
+ ): Promise<SpriteProcessStateV1> {
1002
+ const bounded = Math.max(
1003
+ 1,
1004
+ Math.min(tailBytes, PROCESS_LOG_MAX_TAIL_BYTES),
1005
+ );
1006
+ const directory = this.processDirectory(layout, processId);
1007
+ // No human-control guard, and deliberately: reading a process's outcome is
1008
+ // not the Bot acting on the Computer, and a Routine collecting the result
1009
+ // of a long job must not be blocked because a human is holding the screen.
1010
+ // The stamp still runs, because a tenant with a running process is a live
1011
+ // tenant and its display slot must not be reclaimed under it.
1012
+ const script = [
1013
+ this.tenantStamp(layout),
1014
+ `DIR=${shellQuote(directory)}`,
1015
+ 'PID=$(cat "$DIR/pid" 2>/dev/null || echo 0)',
1016
+ `printf '%salive=%s\\n' ${shellQuote(PROCESS_MARKER)} "$(kill -0 "$PID" 2>/dev/null && echo 1 || echo 0)"`,
1017
+ `if [ -f "$DIR/exit" ]; then printf '%sexit=%s\\n' ${shellQuote(PROCESS_MARKER)} "$(cat "$DIR/exit")"; fi`,
1018
+ `printf '%slog\\n' ${shellQuote(PROCESS_MARKER)}`,
1019
+ `if [ -s "$DIR/log.head" ]; then head -c ${bounded} "$DIR/log.head"; fi`,
1020
+ `if [ -s "$DIR/log.tail" ]; then printf '\\n… earlier output dropped …\\n'; tail -c ${bounded} "$DIR/log.tail"; fi`,
1021
+ ].join("\n");
1022
+ const outcome = await this.execute(
1023
+ host,
1024
+ script,
1025
+ {
1026
+ signal,
1027
+ timeoutMs: TIMEOUTS.control,
1028
+ maxOutputBytes: PROCESS_LOG_MAX_TAIL_BYTES * 4,
1029
+ },
1030
+ "Sprite background read failed",
1031
+ );
1032
+ const raw = outputText(outcome.stdout);
1033
+ const alive = new RegExp(`${PROCESS_MARKER}alive=1`).test(raw);
1034
+ const exit = new RegExp(`${PROCESS_MARKER}exit=(-?\\d+)`).exec(raw);
1035
+ const logIndex = raw.indexOf(`${PROCESS_MARKER}log\n`);
1036
+ const logTail =
1037
+ logIndex < 0 ? "" : raw.slice(logIndex + `${PROCESS_MARKER}log\n`.length);
1038
+ return {
1039
+ alive,
1040
+ ...(exit ? { exitCode: Number(exit[1]) } : {}),
1041
+ logTail: clipped(logTail, bounded * 2),
1042
+ };
1043
+ }
1044
+
1045
+ async browserForAgent(
1046
+ layout: AgentLayout,
1047
+ action: BrowserAction,
1048
+ signal: AbortSignal,
1049
+ ): Promise<string> {
1050
+ const host = await this.readyHost(layout, signal);
1051
+ const encoded = Buffer.from(JSON.stringify(action)).toString("base64url");
1052
+ const script = [
1053
+ this.agentControlGuard(layout),
1054
+ `PORT=$(cat ${layout.runtimeDir}/cdp-port)`,
1055
+ `node ${RUNTIME_ROOT}/browser.mjs "$PORT" ${shellQuote(encoded)}`,
1056
+ ].join("\n");
1057
+ const outcome = await this.execute(
1058
+ host,
1059
+ script,
1060
+ { signal, timeoutMs: TIMEOUTS.browser, maxOutputBytes: MAX_OUTPUT * 2 },
1061
+ "Sprite browser action failed",
1062
+ );
1063
+ return clipped(
1064
+ outputText(outcome.stdout).trim() || outputText(outcome.stderr).trim(),
1065
+ );
1066
+ }
1067
+
1068
+ control(
1069
+ layout: AgentLayout,
1070
+ action: "acquire" | "renew",
1071
+ signal?: AbortSignal,
1072
+ request?: ComputerControlRequestV1,
1073
+ effectId?: string,
1074
+ ): Promise<ComputerHostControlResultV1> {
1075
+ const lease = request ?? {
1076
+ scope: "desktop-gui" as const,
1077
+ ownerId: this.humanControlOwnerId,
1078
+ };
1079
+ return this.hostFor(layout).control(
1080
+ action,
1081
+ lease.ownerId ?? this.humanControlOwnerId,
1082
+ LEASE_MAX_AGE_SECONDS,
1083
+ {
1084
+ signal,
1085
+ effectId,
1086
+ timeoutMs: TIMEOUTS.control,
1087
+ ...(lease.scope ? { scope: lease.scope } : {}),
1088
+ },
1089
+ );
1090
+ }
1091
+
1092
+ async releaseForAgent(
1093
+ layout: AgentLayout,
1094
+ signal?: AbortSignal,
1095
+ request?: ComputerControlRequestV1,
1096
+ effectId?: string,
1097
+ ): Promise<void> {
1098
+ if (!this.host) return;
1099
+ const lease = request ?? {
1100
+ scope: "desktop-gui" as const,
1101
+ ownerId: this.humanControlOwnerId,
1102
+ };
1103
+ try {
1104
+ await this.hostFor(layout).control(
1105
+ "release",
1106
+ lease.ownerId ?? this.humanControlOwnerId,
1107
+ LEASE_MAX_AGE_SECONDS,
1108
+ {
1109
+ signal,
1110
+ effectId,
1111
+ timeoutMs: TIMEOUTS.control,
1112
+ ...(lease.scope ? { scope: lease.scope } : {}),
1113
+ },
1114
+ );
1115
+ } catch (error) {
1116
+ // A Computer that is not there holds no lease to release. Every other
1117
+ // refusal is real and the caller has to see it.
1118
+ if (error instanceof ComputerError && error.code === "conflict") return;
1119
+ throw error;
1120
+ }
1121
+ }
1122
+
1123
+ viewerForAgent(
1124
+ layout: AgentLayout,
1125
+ action: "open" | "renew" | "revoke",
1126
+ signal?: AbortSignal,
1127
+ sessionId?: string,
1128
+ effectId?: string,
1129
+ ): Promise<ComputerHostViewerResultV1> {
1130
+ return this.hostFor(layout).viewer(action, {
1131
+ signal,
1132
+ effectId,
1133
+ timeoutMs: TIMEOUTS.viewer,
1134
+ ...(sessionId === undefined ? {} : { sessionId }),
1135
+ });
1136
+ }
1137
+
1138
+ // --- internals -----------------------------------------------------------
1139
+
1140
+ /**
1141
+ * Creates the tenant's durable directories once per Computer.
1142
+ *
1143
+ * The Workspace and the sync both assume their roots exist. They are made
1144
+ * here rather than by the host's `open` because storage is reachable while
1145
+ * the tenant has no desktop: a Turn that only reads files must not have to
1146
+ * provision a screen first.
1147
+ */
1148
+ private readyStorage(
1149
+ layout: AgentLayout,
1150
+ host: ComputerHostSurfaceV1,
1151
+ signal?: AbortSignal,
1152
+ ): Promise<unknown> {
1153
+ let held = this.storagePromises.get(layout.key);
1154
+ if (!held) {
1155
+ held = this.execute(
1156
+ host,
1157
+ `mkdir -p ${[
1158
+ layout.workspaceDir,
1159
+ `${DATA_ROOT}/agents/${layout.key}/memory`,
1160
+ `${DATA_ROOT}/agents/${layout.key}/skills`,
1161
+ `${DATA_ROOT}/user-memory`,
1162
+ `${DATA_ROOT}/user-packages`,
1163
+ `${RUNTIME_ROOT}/sync`,
1164
+ ]
1165
+ .map(shellQuote)
1166
+ .join(" ")}\n`,
1167
+ { signal, timeoutMs: TIMEOUTS.control, maxOutputBytes: MAX_OUTPUT },
1168
+ "Sprite storage operation failed",
1169
+ ).catch((error: unknown) => {
1170
+ this.storagePromises.delete(layout.key);
1171
+ throw error;
1172
+ });
1173
+ this.storagePromises.set(layout.key, held);
1174
+ }
1175
+ return held;
1176
+ }
1177
+
1178
+ private hostFor(layout: AgentLayout): ComputerHostSurfaceV1 {
1179
+ if (!this.host) {
1180
+ throw new ComputerError(
1181
+ "provider-unavailable",
1182
+ "Set SPRITES_TOKEN to attach a Fly Sprite computer",
1183
+ );
1184
+ }
1185
+ let held = this.surfaces.get(layout.key);
1186
+ if (!held) {
1187
+ held = this.host(this.identity, { botId: layout.identity.id });
1188
+ this.surfaces.set(layout.key, held);
1189
+ }
1190
+ return held;
1191
+ }
1192
+
1193
+ private async readyHost(
1194
+ layout: AgentLayout,
1195
+ signal?: AbortSignal,
1196
+ ): Promise<ComputerHostSurfaceV1> {
1197
+ await this.ensureAgent(layout, signal);
1198
+ return this.hostFor(layout);
1199
+ }
1200
+
1201
+ /**
1202
+ * Opens the Computer and attaches this tenant to it.
1203
+ *
1204
+ * One call: the host provisions the Sprite if it must, runs the ensure
1205
+ * script, allocates a display slot, and answers with the tenant's directory
1206
+ * and generation. The Durable Object no longer sequences provisioning steps
1207
+ * because it can no longer see the Sprite — and that is the point.
1208
+ */
1209
+ private async openAgent(
1210
+ layout: AgentLayout,
1211
+ options?: ComputerOperationOptions,
1212
+ ): Promise<ComputerConnection> {
1213
+ const signal = options?.signal;
1214
+ const effectId = options?.effectId;
1215
+ const host = this.hostFor(layout);
1216
+ let opened: ComputerHostOpenResultV1;
1217
+ try {
1218
+ opened = await host.open({
1219
+ signal,
1220
+ timeoutMs: TIMEOUTS.open,
1221
+ ...(effectId ? { effectId: `${effectId}:open` } : {}),
1222
+ });
1223
+ } catch (error) {
1224
+ // Every display belonging to a tenant this Computer still has open is a
1225
+ // declared outcome, not a crash: the alternative would be two Bots
1226
+ // sharing one screen, and Bots are separated on a Computer exactly so
1227
+ // that does not happen silently.
1228
+ if (isSlotExhaustion(error)) {
1229
+ throw new ComputerError(
1230
+ "capability-unavailable",
1231
+ `Every desktop on this Computer is in use; Bot "${layout.identity.id}" has no display until one is idle`,
1232
+ true,
1233
+ { cause: error },
1234
+ );
1235
+ }
1236
+ throw error;
1237
+ }
1238
+ this.expectedSpriteName = opened.spriteName;
1239
+ this.generations.set(layout.key, opened.generation);
1240
+ if (opened.display) this.displays.set(layout.key, opened.display);
1241
+ if (this.respectHumanControl) {
1242
+ await this.assertAgentControl(
1243
+ host,
1244
+ layout,
1245
+ signal,
1246
+ effectId ? `${effectId}:assert-control` : undefined,
1247
+ );
1248
+ }
1249
+ const viewer = await host.viewer("open", {
1250
+ signal,
1251
+ timeoutMs: TIMEOUTS.viewer,
1252
+ ...(effectId ? { effectId: `${effectId}:viewer` } : {}),
1253
+ });
1254
+ if (!viewer.session) {
1255
+ throw new ComputerError(
1256
+ "provider-unavailable",
1257
+ "The Computer host returned no viewer session",
1258
+ true,
1259
+ );
1260
+ }
1261
+ return {
1262
+ botId: layout.identity.id,
1263
+ botKey: layout.key,
1264
+ spriteName: opened.spriteName,
1265
+ viewerUrl: viewer.session.url,
1266
+ viewerSessionId: viewer.session.id,
1267
+ ...(viewer.session.expiresAt
1268
+ ? { viewerExpiresAt: viewer.session.expiresAt }
1269
+ : {}),
1270
+ display: opened.display ?? "",
1271
+ directory: `agent-data/agents/${layout.key}`,
1272
+ // A run the host finished inside this open is history, not progress.
1273
+ // Reporting its final "complete" label as an update would leave the Bot
1274
+ // authority holding `updating` for a Computer that is already current.
1275
+ ...(opened.provisioning && opened.provisioning.status === "running"
1276
+ ? { message: provisioningMessage(opened.provisioning) }
1277
+ : {}),
1278
+ };
1279
+ }
1280
+
1281
+ private async assertAgentControl(
1282
+ host: ComputerHostSurfaceV1,
1283
+ layout: AgentLayout,
1284
+ signal?: AbortSignal,
1285
+ effectId?: string,
1286
+ ): Promise<void> {
1287
+ await this.execute(
1288
+ host,
1289
+ `${CONTROL_SCRIPT} assert-agent ${shellQuote(layout.key)} ${shellQuote(DESKTOP_GUI_LEASE_KEY)} ${shellQuote(this.ownerId)} ${LEASE_MAX_AGE_SECONDS}\n`,
1290
+ {
1291
+ signal,
1292
+ effectId,
1293
+ timeoutMs: TIMEOUTS.control,
1294
+ maxOutputBytes: MAX_OUTPUT,
1295
+ },
1296
+ "Sprite command failed",
1297
+ );
1298
+ }
1299
+
1300
+ /**
1301
+ * The environment every command this provider runs for a tenant starts in.
1302
+ *
1303
+ * `PATH` leads with the shims and then the Computer's own `bin`: a command
1304
+ * that reaches for `chromium` or `xdotool` by name finds the refusal, and
1305
+ * the browser launcher is still reachable by name.
1306
+ * That is policy, not a boundary — the real binaries are still on the box,
1307
+ * one absolute path away — and it is stated as policy in `browser.md` and in
1308
+ * the refusal itself.
1309
+ *
1310
+ * The Bot's working directory stays its own private workspace; the shared
1311
+ * scratch is named in the environment rather than entered, because a default
1312
+ * cwd every Bot of a User shares is a default cwd where files collide.
1313
+ */
1314
+ private tenantEnvironment(layout: AgentLayout): string[] {
1315
+ return [
1316
+ `export HOME=${HOME_ROOT}`,
1317
+ `export PATH=${SHIMS_ROOT}:${BIN_ROOT}:$PATH`,
1318
+ `export FROCKBOT_BOT_ID=${shellQuote(layout.identity.id)}`,
1319
+ `export FROCKBOT_BOT_KEY=${shellQuote(layout.key)}`,
1320
+ `export ${SCRATCH_ENV}=${SCRATCH_ROOT}`,
1321
+ `cd ${shellQuote(layout.workspaceDir)}`,
1322
+ ];
1323
+ }
1324
+
1325
+ /**
1326
+ * Prefixes every command this provider runs for a tenant: the human-control
1327
+ * assertion, and the registry's `last-seen` stamp.
1328
+ *
1329
+ * The stamp is what keeps an exec-only tenant's desktop slot: it never opens
1330
+ * a viewer and never holds an X lock, so without it the slot reclaim would
1331
+ * be free to hand its display to another Bot mid-run.
1332
+ */
1333
+ private agentControlGuard(layout: AgentLayout): string {
1334
+ return [
1335
+ `${CONTROL_SCRIPT} assert-agent ${shellQuote(layout.key)} ${shellQuote(DESKTOP_GUI_LEASE_KEY)} ${shellQuote(this.ownerId)} ${LEASE_MAX_AGE_SECONDS} || exit $?`,
1336
+ this.tenantStamp(layout),
1337
+ ].join("\n");
1338
+ }
1339
+
1340
+ /** The registry's `last-seen` stamp, without the control assertion. */
1341
+ private tenantStamp(layout: AgentLayout): string {
1342
+ const bot = shellQuote(`${BOTS_ROOT}/${layout.key}`);
1343
+ return `mkdir -p ${bot} && touch ${bot}/last-seen`;
1344
+ }
1345
+
1346
+ /**
1347
+ * Runs one script and refuses a non-zero exit.
1348
+ *
1349
+ * A non-zero exit here is the outer document's, not the Bot's command's: the
1350
+ * control guard, a missing directory, an unreadable file. Those are failures
1351
+ * of the operation the caller asked for, so they throw with the label the
1352
+ * caller named, exactly as the SDK's own rejection used to.
1353
+ */
1354
+ private async execute(
1355
+ host: ComputerHostSurfaceV1,
1356
+ script: string,
1357
+ options: {
1358
+ signal?: AbortSignal;
1359
+ effectId?: string;
1360
+ timeoutMs: number;
1361
+ maxOutputBytes: number;
1362
+ },
1363
+ label: string,
1364
+ ): Promise<ComputerHostExecOutcomeV1> {
1365
+ let outcome: ComputerHostExecOutcomeV1;
1366
+ try {
1367
+ outcome = await host.exec(
1368
+ {
1369
+ script,
1370
+ timeoutMs: options.timeoutMs,
1371
+ maxOutputBytes: options.maxOutputBytes,
1372
+ },
1373
+ {
1374
+ ...(options.signal ? { signal: options.signal } : {}),
1375
+ ...(options.effectId ? { effectId: options.effectId } : {}),
1376
+ },
1377
+ );
1378
+ } catch (error) {
1379
+ if (error instanceof ComputerError) throw error;
1380
+ throw new Error(`${label}: ${errorText(error)}`);
1381
+ }
1382
+ if (outcome.exitCode !== 0) {
1383
+ const detail =
1384
+ outputText(outcome.stderr).trim() ||
1385
+ outputText(outcome.stdout).trim() ||
1386
+ `exit ${String(outcome.exitCode)}`;
1387
+ if (outcome.exitCode === 73) {
1388
+ throw new ComputerError("human-control-active", detail);
1389
+ }
1390
+ throw new Error(`${label}: ${detail}`);
1391
+ }
1392
+ return outcome;
1393
+ }
1394
+ }