@frockbot/plugin-fly-sprite 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,794 @@
1
+ /**
2
+ * The Bot Durable Object's side of the shared Computer host (ADR 0004).
3
+ *
4
+ * This is a transport and nothing else. It speaks the v1 protocol of
5
+ * `@frockbot/computer-host-protocol` over the `COMPUTER_HOST` service binding,
6
+ * decodes every answer at the seam, and translates a host failure into the
7
+ * provider-neutral `ComputerError` the Computer interface declares. It holds
8
+ * no Sprites SDK, no `SPRITES_TOKEN`, and no knowledge of what a script does:
9
+ * "Secrets remain server-side and cross interfaces only as opaque references
10
+ * when necessary", so what leaves the Durable Object is a `credentialRef` the
11
+ * host resolves.
12
+ *
13
+ * It lives in this Package rather than in `computer-core` because the protocol
14
+ * it speaks is not provider-neutral: `ComputerHostOpenResultV1` answers with a
15
+ * `spriteName`, and the host it addresses is the Fly Sprites host. "Electron,
16
+ * Cloudflare, provider SDK, and Computer implementation types remain inside
17
+ * their adapters" — this is that adapter, and `computer-core` stays free of
18
+ * both the wire protocol and the binding.
19
+ *
20
+ * Two behaviours are load-bearing and are the reason this is a class rather
21
+ * than a function:
22
+ *
23
+ * - **Framing.** A streamed exec answers NDJSON, and a transport chunk
24
+ * boundary means nothing: a frame may be split across two chunks or three
25
+ * frames may arrive in one. `ComputerHostExecFrameReaderV1` finds the
26
+ * newline; this client never reads a chunk as a frame.
27
+ * - **Cancellation.** "Connections to the Computer are expected to drop on
28
+ * every pause." A caller's abort aborts the fetch *and* posts a `cancel` for
29
+ * the same `effectId`, because a dropped connection alone leaves the process
30
+ * running on the Computer.
31
+ */
32
+
33
+ import { ComputerError, type ComputerErrorCode } from "@frockbot/computer-core";
34
+ import {
35
+ COMPUTER_HOST_LIMITS,
36
+ COMPUTER_HOST_PROTOCOL_VERSION,
37
+ COMPUTER_HOST_ROUTES,
38
+ COMPUTER_HOST_TOKEN_HEADER,
39
+ ComputerHostExecFrameReaderV1,
40
+ decodeComputerHostCancelResultV1,
41
+ decodeComputerHostControlResultV1,
42
+ decodeComputerHostExecResultV1,
43
+ decodeComputerHostFileDeleteResultV1,
44
+ decodeComputerHostFileListResultV1,
45
+ decodeComputerHostFileReadResultV1,
46
+ decodeComputerHostFileStatResultV1,
47
+ decodeComputerHostFileWriteResultV1,
48
+ decodeComputerHostOpenResultV1,
49
+ decodeComputerHostProblemV1,
50
+ decodeComputerHostServiceResultV1,
51
+ decodeComputerHostViewerResultV1,
52
+ encodeComputerHostRequestV1,
53
+ type ComputerHostCancelResultV1,
54
+ type ComputerHostControlActionV1,
55
+ type ComputerHostControlScopeV1,
56
+ type ComputerHostControlResultV1,
57
+ type ComputerHostErrorCodeV1,
58
+ type ComputerHostFileDeleteResultV1,
59
+ type ComputerHostFileListResultV1,
60
+ type ComputerHostFileReadResultV1,
61
+ type ComputerHostFileStatResultV1,
62
+ type ComputerHostFileWriteResultV1,
63
+ type ComputerHostOpenResultV1,
64
+ type ComputerHostOperationKindV1,
65
+ type ComputerHostOperationV1,
66
+ type ComputerHostServiceResultV1,
67
+ type ComputerHostViewerResultV1,
68
+ } from "@frockbot/computer-host-protocol";
69
+
70
+ /**
71
+ * The origin every request is addressed to. A service binding routes by
72
+ * binding and ignores the host, so this names the seam rather than a network
73
+ * location: the Computer host has no public route (`apps/computer-host`
74
+ * declares none) and is unreachable except through the binding.
75
+ */
76
+ export const COMPUTER_HOST_ORIGIN = "http://computer-host.internal";
77
+
78
+ /** How long past a caller's own timeout the client waits before giving up. */
79
+ export const COMPUTER_HOST_TIMEOUT_GRACE_MS = 5_000;
80
+
81
+ /** The deadline for a call that declares none of its own. */
82
+ export const COMPUTER_HOST_DEFAULT_TIMEOUT_MS = 120_000;
83
+
84
+ /** The deadline for a best-effort cancel, which must never outlive its caller. */
85
+ const CANCEL_TIMEOUT_MS = 5_000;
86
+
87
+ /**
88
+ * The `COMPUTER_HOST` service binding, narrowed to the one method used.
89
+ * A Worker `Fetcher` satisfies it; so does a test double, which is the point.
90
+ */
91
+ export interface ComputerHostFetcherV1 {
92
+ fetch(request: Request): Promise<Response>;
93
+ }
94
+
95
+ export interface ComputerHostClientOptions {
96
+ fetcher: ComputerHostFetcherV1;
97
+ /**
98
+ * The shared secret between this Worker, the host Worker, and the container.
99
+ * The service binding is already unroutable; this is the second lock, so a
100
+ * container reached by any other path still refuses.
101
+ */
102
+ hostToken: string;
103
+ /** Whose Computer this is. One Computer per User (ADR 0012). */
104
+ identity: { userId: string };
105
+ /** The Bot making the call, a tenant on that Computer. */
106
+ tenant: { botId: string };
107
+ /**
108
+ * The opaque reference the host resolves to a credential. It carries no
109
+ * credential material and is `sprites:user:<userId>` unless a caller names
110
+ * another; shipping it from day one is what lets the ADR 0004 credential
111
+ * broker land without a protocol version bump.
112
+ */
113
+ credentialRef?: string;
114
+ origin?: string;
115
+ timeoutGraceMs?: number;
116
+ /**
117
+ * Mints the effect identifier for a call that supplies none. The Durable
118
+ * Object supplies one for every effect it has recorded; this covers the
119
+ * calls that have no recorded intent, so a cancel can still name what it is
120
+ * cancelling.
121
+ */
122
+ newEffectId?: () => string;
123
+ }
124
+
125
+ export interface ComputerHostCallOptions {
126
+ signal?: AbortSignal;
127
+ effectId?: string;
128
+ /** Overrides the deadline this call is given, in milliseconds. */
129
+ timeoutMs?: number;
130
+ }
131
+
132
+ export interface ComputerHostExecCommandV1 {
133
+ /** Shell source delivered on the command's stdin. Never on its argv. */
134
+ script: string;
135
+ cwd?: string;
136
+ env?: Record<string, string>;
137
+ /** Extra stdin appended after the script. */
138
+ stdin?: Uint8Array;
139
+ timeoutMs?: number;
140
+ maxOutputBytes?: number;
141
+ /**
142
+ * NDJSON frames rather than one buffered answer. On by default: a streamed
143
+ * exec bounds its output as it arrives and its cancel reaches a process that
144
+ * is still running.
145
+ */
146
+ stream?: boolean;
147
+ }
148
+
149
+ export interface ComputerHostExecOutcomeV1 {
150
+ effectId: string;
151
+ exitCode: number | null;
152
+ signal?: string;
153
+ stdout: Uint8Array;
154
+ stderr: Uint8Array;
155
+ /** True when the host truncated, or when this client stopped accumulating. */
156
+ outputTruncated: boolean;
157
+ }
158
+
159
+ const EMPTY = new Uint8Array(0);
160
+
161
+ /**
162
+ * The host's failure vocabulary mapped onto the Computer interface's.
163
+ *
164
+ * `timeout` becomes `provider-unavailable` rather than a code of its own: to
165
+ * the Bot, a Computer that did not answer in time is a Computer that is not
166
+ * available right now, and the retry decision is the same one.
167
+ */
168
+ const ERROR_CODES: Record<ComputerHostErrorCodeV1, ComputerErrorCode> = {
169
+ "invalid-request": "invalid-request",
170
+ // The token is wrong or missing. That is a deployment fault, not a Computer
171
+ // fault, and retrying it changes nothing.
172
+ "not-authorized": "provider-failure",
173
+ "not-found": "provider-failure",
174
+ conflict: "conflict",
175
+ "limit-exceeded": "limit-exceeded",
176
+ "human-control-active": "human-control-active",
177
+ "computer-updating": "updating",
178
+ aborted: "aborted",
179
+ timeout: "provider-unavailable",
180
+ "provider-unavailable": "provider-unavailable",
181
+ "provider-failure": "provider-failure",
182
+ };
183
+
184
+ function toBytes(base64: string): Uint8Array {
185
+ if (!base64) return EMPTY;
186
+ const binary = atob(base64);
187
+ const bytes = new Uint8Array(binary.length);
188
+ for (let index = 0; index < binary.length; index += 1) {
189
+ bytes[index] = binary.charCodeAt(index);
190
+ }
191
+ return bytes;
192
+ }
193
+
194
+ function fromBytes(bytes: Uint8Array): string {
195
+ let binary = "";
196
+ for (const byte of bytes) binary += String.fromCharCode(byte);
197
+ return btoa(binary);
198
+ }
199
+
200
+ function concat(parts: readonly Uint8Array[], total: number): Uint8Array {
201
+ if (parts.length === 1) return parts[0] ?? EMPTY;
202
+ const joined = new Uint8Array(total);
203
+ let offset = 0;
204
+ for (const part of parts) {
205
+ joined.set(part, offset);
206
+ offset += part.byteLength;
207
+ }
208
+ return joined;
209
+ }
210
+
211
+ /** Accumulates one output stream up to a declared ceiling, and no further. */
212
+ class BoundedOutput {
213
+ private readonly parts: Uint8Array[] = [];
214
+ private length = 0;
215
+ truncated = false;
216
+
217
+ constructor(private readonly limit: number) {}
218
+
219
+ push(chunk: Uint8Array): void {
220
+ if (!chunk.byteLength) return;
221
+ const room = this.limit - this.length;
222
+ if (room <= 0) {
223
+ this.truncated = true;
224
+ return;
225
+ }
226
+ if (chunk.byteLength > room) {
227
+ this.parts.push(chunk.subarray(0, room));
228
+ this.length += room;
229
+ this.truncated = true;
230
+ return;
231
+ }
232
+ this.parts.push(chunk);
233
+ this.length += chunk.byteLength;
234
+ }
235
+
236
+ bytes(): Uint8Array {
237
+ return this.parts.length ? concat(this.parts, this.length) : EMPTY;
238
+ }
239
+ }
240
+
241
+ function defaultEffectId(): string {
242
+ return crypto.randomUUID();
243
+ }
244
+
245
+ /**
246
+ * A call in flight: its deadline, the caller's abort, and which of the two
247
+ * fired.
248
+ *
249
+ * They are one object because the answer to "why did this stop" decides the
250
+ * `ComputerError` the caller sees, and reading it off a bare `AbortSignal`
251
+ * cannot tell a caller's cancellation from an expired deadline.
252
+ */
253
+ class CallLease {
254
+ readonly controller = new AbortController();
255
+ private timer: ReturnType<typeof setTimeout> | undefined;
256
+ private detach: (() => void) | undefined;
257
+ timedOut = false;
258
+
259
+ constructor(
260
+ deadlineMs: number,
261
+ private readonly caller: AbortSignal | undefined,
262
+ ) {
263
+ this.timer = setTimeout(() => {
264
+ this.timedOut = true;
265
+ this.controller.abort();
266
+ }, deadlineMs);
267
+ if (caller) {
268
+ if (caller.aborted) {
269
+ this.controller.abort();
270
+ } else {
271
+ const onAbort = () => this.controller.abort();
272
+ caller.addEventListener("abort", onAbort, { once: true });
273
+ this.detach = () => caller.removeEventListener("abort", onAbort);
274
+ }
275
+ }
276
+ }
277
+
278
+ get callerAborted(): boolean {
279
+ return this.caller?.aborted === true;
280
+ }
281
+
282
+ release(): void {
283
+ if (this.timer !== undefined) clearTimeout(this.timer);
284
+ this.timer = undefined;
285
+ this.detach?.();
286
+ this.detach = undefined;
287
+ }
288
+ }
289
+
290
+ export class ComputerHostClient {
291
+ private readonly fetcher: ComputerHostFetcherV1;
292
+ private readonly hostToken: string;
293
+ private readonly origin: string;
294
+ private readonly grace: number;
295
+ private readonly newEffectId: () => string;
296
+ readonly identity: { userId: string };
297
+ readonly tenant: { botId: string };
298
+ readonly credentialRef: string;
299
+
300
+ constructor(options: ComputerHostClientOptions) {
301
+ this.fetcher = options.fetcher;
302
+ this.hostToken = options.hostToken;
303
+ this.identity = { userId: options.identity.userId };
304
+ this.tenant = { botId: options.tenant.botId };
305
+ this.credentialRef =
306
+ options.credentialRef ?? `sprites:user:${options.identity.userId}`;
307
+ this.origin = options.origin ?? COMPUTER_HOST_ORIGIN;
308
+ this.grace = options.timeoutGraceMs ?? COMPUTER_HOST_TIMEOUT_GRACE_MS;
309
+ this.newEffectId = options.newEffectId ?? defaultEffectId;
310
+ }
311
+
312
+ /** A client for another Bot on the same User's Computer. */
313
+ forTenant(botId: string): ComputerHostClient {
314
+ return new ComputerHostClient({
315
+ fetcher: this.fetcher,
316
+ hostToken: this.hostToken,
317
+ identity: this.identity,
318
+ tenant: { botId },
319
+ credentialRef: this.credentialRef,
320
+ origin: this.origin,
321
+ timeoutGraceMs: this.grace,
322
+ newEffectId: this.newEffectId,
323
+ });
324
+ }
325
+
326
+ open(options?: ComputerHostCallOptions): Promise<ComputerHostOpenResultV1> {
327
+ return this.json({ kind: "open" }, decodeComputerHostOpenResultV1, options);
328
+ }
329
+
330
+ /**
331
+ * Runs one bash document on the Computer.
332
+ *
333
+ * The script travels in the request body and reaches the command on its
334
+ * stdin. It is never argv: the Sprites SDK appends every argv element to the
335
+ * request URL, and a provisioning script answered HTTP 431 — the measurement
336
+ * recorded in ADR 0004 and the reason this seam exists.
337
+ */
338
+ async exec(
339
+ command: ComputerHostExecCommandV1,
340
+ options?: ComputerHostCallOptions,
341
+ ): Promise<ComputerHostExecOutcomeV1> {
342
+ const timeoutMs = Math.max(
343
+ 1,
344
+ Math.min(
345
+ command.timeoutMs ?? COMPUTER_HOST_DEFAULT_TIMEOUT_MS,
346
+ COMPUTER_HOST_LIMITS.execTimeoutMs,
347
+ ),
348
+ );
349
+ const maxOutputBytes = Math.max(
350
+ 1,
351
+ Math.min(
352
+ command.maxOutputBytes ?? COMPUTER_HOST_LIMITS.maxOutputBytes,
353
+ COMPUTER_HOST_LIMITS.maxOutputBytes,
354
+ ),
355
+ );
356
+ const stream = command.stream ?? true;
357
+ const operation: ComputerHostOperationV1 = {
358
+ kind: "exec",
359
+ script: command.script,
360
+ ...(command.cwd === undefined ? {} : { cwd: command.cwd }),
361
+ ...(command.env === undefined ? {} : { env: command.env }),
362
+ ...(command.stdin === undefined
363
+ ? {}
364
+ : { stdinBase64: fromBytes(command.stdin) }),
365
+ timeoutMs,
366
+ maxOutputBytes,
367
+ stream,
368
+ };
369
+ const effectId = this.effectIdFor(options);
370
+ const lease = this.lease(timeoutMs, options);
371
+ let response: Response;
372
+ try {
373
+ response = await this.send(operation, effectId, lease);
374
+ } catch (error) {
375
+ lease.release();
376
+ throw error;
377
+ }
378
+ if (!stream) {
379
+ try {
380
+ const result = decodeComputerHostExecResultV1(
381
+ await this.body(response, lease, effectId),
382
+ );
383
+ return {
384
+ effectId,
385
+ exitCode: result.exitCode,
386
+ ...(result.signal ? { signal: result.signal } : {}),
387
+ stdout: toBytes(result.stdoutBase64),
388
+ stderr: toBytes(result.stderrBase64),
389
+ outputTruncated: result.outputTruncated,
390
+ };
391
+ } finally {
392
+ lease.release();
393
+ }
394
+ }
395
+ try {
396
+ return await this.drain(response, lease, effectId, maxOutputBytes);
397
+ } finally {
398
+ lease.release();
399
+ }
400
+ }
401
+
402
+ fileRead(
403
+ path: string,
404
+ options?: ComputerHostCallOptions,
405
+ ): Promise<ComputerHostFileReadResultV1> {
406
+ return this.json(
407
+ { kind: "file/read", path },
408
+ decodeComputerHostFileReadResultV1,
409
+ options,
410
+ );
411
+ }
412
+
413
+ fileWrite(
414
+ path: string,
415
+ bytes: Uint8Array,
416
+ options?: ComputerHostCallOptions & { mode?: number },
417
+ ): Promise<ComputerHostFileWriteResultV1> {
418
+ return this.json(
419
+ {
420
+ kind: "file/write",
421
+ path,
422
+ bytesBase64: fromBytes(bytes),
423
+ ...(options?.mode === undefined ? {} : { mode: options.mode }),
424
+ },
425
+ decodeComputerHostFileWriteResultV1,
426
+ options,
427
+ );
428
+ }
429
+
430
+ fileList(
431
+ path: string,
432
+ options?: ComputerHostCallOptions & { recursive?: boolean },
433
+ ): Promise<ComputerHostFileListResultV1> {
434
+ return this.json(
435
+ { kind: "file/list", path, recursive: options?.recursive ?? false },
436
+ decodeComputerHostFileListResultV1,
437
+ options,
438
+ );
439
+ }
440
+
441
+ fileStat(
442
+ path: string,
443
+ options?: ComputerHostCallOptions,
444
+ ): Promise<ComputerHostFileStatResultV1> {
445
+ return this.json(
446
+ { kind: "file/stat", path },
447
+ decodeComputerHostFileStatResultV1,
448
+ options,
449
+ );
450
+ }
451
+
452
+ fileDelete(
453
+ path: string,
454
+ options?: ComputerHostCallOptions & { recursive?: boolean },
455
+ ): Promise<ComputerHostFileDeleteResultV1> {
456
+ return this.json(
457
+ { kind: "file/delete", path, recursive: options?.recursive ?? false },
458
+ decodeComputerHostFileDeleteResultV1,
459
+ options,
460
+ );
461
+ }
462
+
463
+ control(
464
+ action: ComputerHostControlActionV1,
465
+ ownerId: string,
466
+ maxAgeSeconds: number,
467
+ options?: ComputerHostCallOptions & {
468
+ scope?: ComputerHostControlScopeV1;
469
+ },
470
+ ): Promise<ComputerHostControlResultV1> {
471
+ return this.json(
472
+ {
473
+ kind: "control",
474
+ action,
475
+ ownerId,
476
+ maxAgeSeconds,
477
+ // Absent ⇒ legacy `bot`. Human sessions and `computerUse` explicitly
478
+ // name the User-wide `desktop-gui` screen lease.
479
+ ...(options?.scope === undefined ? {} : { scope: options.scope }),
480
+ },
481
+ decodeComputerHostControlResultV1,
482
+ options,
483
+ );
484
+ }
485
+
486
+ viewer(
487
+ action: "open" | "renew" | "revoke",
488
+ options?: ComputerHostCallOptions & { sessionId?: string },
489
+ ): Promise<ComputerHostViewerResultV1> {
490
+ return this.json(
491
+ {
492
+ kind: "viewer",
493
+ action,
494
+ ...(options?.sessionId === undefined
495
+ ? {}
496
+ : { sessionId: options.sessionId }),
497
+ },
498
+ decodeComputerHostViewerResultV1,
499
+ options,
500
+ );
501
+ }
502
+
503
+ service(
504
+ name: string,
505
+ options?: ComputerHostCallOptions,
506
+ ): Promise<ComputerHostServiceResultV1> {
507
+ return this.json(
508
+ { kind: "service", name },
509
+ decodeComputerHostServiceResultV1,
510
+ options,
511
+ );
512
+ }
513
+
514
+ /**
515
+ * Cancels the effect this identifier names.
516
+ *
517
+ * There is no second identifier in the DTO: the envelope's `effectId` *is*
518
+ * what is being cancelled, so a cancel cannot disagree with itself.
519
+ */
520
+ cancel(
521
+ effectId: string,
522
+ options?: ComputerHostCallOptions,
523
+ ): Promise<ComputerHostCancelResultV1> {
524
+ return this.json({ kind: "cancel" }, decodeComputerHostCancelResultV1, {
525
+ ...options,
526
+ effectId,
527
+ timeoutMs: options?.timeoutMs ?? CANCEL_TIMEOUT_MS,
528
+ });
529
+ }
530
+
531
+ // --- internals -----------------------------------------------------------
532
+
533
+ private effectIdFor(options: ComputerHostCallOptions | undefined): string {
534
+ return options?.effectId?.trim() || this.newEffectId();
535
+ }
536
+
537
+ /**
538
+ * The client's own deadline: the host's, plus a grace.
539
+ *
540
+ * The grace is what lets the host's per-phase timeout answer first. Without
541
+ * it the two deadlines race, and a command that the host killed cleanly at
542
+ * 120 s would reach the caller as an unexplained transport failure instead
543
+ * of the `timeout` problem the host wrote.
544
+ */
545
+ private lease(
546
+ timeoutMs: number,
547
+ options: ComputerHostCallOptions | undefined,
548
+ ): CallLease {
549
+ return new CallLease(
550
+ (options?.timeoutMs ?? timeoutMs) + this.grace,
551
+ options?.signal,
552
+ );
553
+ }
554
+
555
+ private async json<T>(
556
+ operation: ComputerHostOperationV1,
557
+ decode: (input: unknown) => T,
558
+ options: ComputerHostCallOptions | undefined,
559
+ ): Promise<T> {
560
+ const effectId = this.effectIdFor(options);
561
+ const lease = this.lease(COMPUTER_HOST_DEFAULT_TIMEOUT_MS, options);
562
+ try {
563
+ const response = await this.send(operation, effectId, lease);
564
+ return decode(await this.body(response, lease, effectId));
565
+ } finally {
566
+ lease.release();
567
+ }
568
+ }
569
+
570
+ private async send(
571
+ operation: ComputerHostOperationV1,
572
+ effectId: string,
573
+ lease: CallLease,
574
+ ): Promise<Response> {
575
+ const kind: ComputerHostOperationKindV1 = operation.kind;
576
+ const body = JSON.stringify(
577
+ encodeComputerHostRequestV1({
578
+ version: COMPUTER_HOST_PROTOCOL_VERSION,
579
+ effectId,
580
+ identity: this.identity,
581
+ tenant: this.tenant,
582
+ credentialRef: this.credentialRef,
583
+ operation,
584
+ }),
585
+ );
586
+ let response: Response;
587
+ try {
588
+ response = await this.fetcher.fetch(
589
+ new Request(`${this.origin}${COMPUTER_HOST_ROUTES[kind]}`, {
590
+ method: "POST",
591
+ headers: {
592
+ "content-type": "application/json",
593
+ [COMPUTER_HOST_TOKEN_HEADER]: this.hostToken,
594
+ },
595
+ body,
596
+ signal: lease.controller.signal,
597
+ }),
598
+ );
599
+ } catch (error) {
600
+ throw this.refuse(error, lease, effectId, kind !== "cancel");
601
+ }
602
+ if (response.ok) return response;
603
+ throw await this.problem(response, lease, effectId);
604
+ }
605
+
606
+ /** Reads one JSON answer, translating a mid-read abort like any other. */
607
+ private async body(
608
+ response: Response,
609
+ lease: CallLease,
610
+ effectId: string,
611
+ ): Promise<unknown> {
612
+ try {
613
+ return await response.json();
614
+ } catch (error) {
615
+ throw this.refuse(error, lease, effectId, false);
616
+ }
617
+ }
618
+
619
+ /**
620
+ * Reads an NDJSON exec stream to its end.
621
+ *
622
+ * Output past `maxOutputBytes` is dropped rather than accumulated, and the
623
+ * stream is still read to completion: the exit frame is what says whether
624
+ * the command succeeded, and abandoning the read to save bytes would trade a
625
+ * known outcome for an unknown one.
626
+ */
627
+ private async drain(
628
+ response: Response,
629
+ lease: CallLease,
630
+ effectId: string,
631
+ maxOutputBytes: number,
632
+ ): Promise<ComputerHostExecOutcomeV1> {
633
+ if (!response.body) {
634
+ throw new ComputerError(
635
+ "provider-failure",
636
+ "The Computer host answered an exec stream with no body",
637
+ );
638
+ }
639
+ const reader = response.body.getReader();
640
+ const frames = new ComputerHostExecFrameReaderV1();
641
+ const stdout = new BoundedOutput(maxOutputBytes);
642
+ const stderr = new BoundedOutput(maxOutputBytes);
643
+ let exit: { exitCode: number | null; signal?: string } | undefined;
644
+ let hostTruncated = false;
645
+ let failure: ComputerError | undefined;
646
+
647
+ const consume = (
648
+ batch: ReturnType<ComputerHostExecFrameReaderV1["push"]>,
649
+ ) => {
650
+ for (const frame of batch) {
651
+ if (frame.type === "stdout") {
652
+ stdout.push(toBytes(frame.dataBase64));
653
+ } else if (frame.type === "stderr") {
654
+ stderr.push(toBytes(frame.dataBase64));
655
+ } else if (frame.type === "exit") {
656
+ exit = {
657
+ exitCode: frame.exitCode,
658
+ ...(frame.signal ? { signal: frame.signal } : {}),
659
+ };
660
+ hostTruncated = frame.outputTruncated;
661
+ } else {
662
+ failure ??= new ComputerError(
663
+ ERROR_CODES[frame.code],
664
+ frame.message,
665
+ frame.retryable,
666
+ );
667
+ }
668
+ }
669
+ };
670
+
671
+ try {
672
+ for (;;) {
673
+ const { done, value } = await reader.read();
674
+ if (done) break;
675
+ if (value) consume(frames.push(value));
676
+ }
677
+ consume(frames.end());
678
+ } catch (error) {
679
+ throw this.refuse(error, lease, effectId, true);
680
+ } finally {
681
+ reader.releaseLock();
682
+ }
683
+
684
+ if (failure) throw failure;
685
+ if (!exit) {
686
+ // The stream ended without an outcome. That is the shape of a container
687
+ // that restarted mid-exec: "In-flight exec dies; the DO sees a stream
688
+ // error", and the effect's outcome is unknown rather than failed.
689
+ throw new ComputerError(
690
+ "provider-unavailable",
691
+ "The Computer host exec stream ended before the command exited",
692
+ true,
693
+ );
694
+ }
695
+ return {
696
+ effectId,
697
+ exitCode: exit.exitCode,
698
+ ...(exit.signal ? { signal: exit.signal } : {}),
699
+ stdout: stdout.bytes(),
700
+ stderr: stderr.bytes(),
701
+ outputTruncated: hostTruncated || stdout.truncated || stderr.truncated,
702
+ };
703
+ }
704
+
705
+ /** Turns a non-2xx answer into the `ComputerError` its problem body declares. */
706
+ private async problem(
707
+ response: Response,
708
+ lease: CallLease,
709
+ effectId: string,
710
+ ): Promise<ComputerError> {
711
+ let decoded: ReturnType<typeof decodeComputerHostProblemV1> | undefined;
712
+ try {
713
+ decoded = decodeComputerHostProblemV1(await response.json());
714
+ } catch {
715
+ decoded = undefined;
716
+ }
717
+ if (!decoded) {
718
+ // A body this client cannot decode is still a refusal, and the status is
719
+ // the only thing left that means anything. 429 is the load shed the
720
+ // container declares; everything else is the host misbehaving.
721
+ return response.status === 429
722
+ ? new ComputerError(
723
+ "limit-exceeded",
724
+ "The Computer host is shedding load",
725
+ true,
726
+ )
727
+ : new ComputerError(
728
+ "provider-failure",
729
+ `The Computer host answered ${response.status} with an undecodable body`,
730
+ response.status >= 500,
731
+ );
732
+ }
733
+ if (decoded.code === "aborted" && lease.callerAborted) {
734
+ this.cancelQuietly(effectId);
735
+ }
736
+ return new ComputerError(
737
+ ERROR_CODES[decoded.code],
738
+ decoded.message,
739
+ decoded.retryable,
740
+ );
741
+ }
742
+
743
+ /**
744
+ * Classifies a thrown transport failure.
745
+ *
746
+ * Three things look alike at the `fetch` seam and mean different things: the
747
+ * caller cancelled, the deadline expired, or the host never answered. Only
748
+ * the first is the caller's own doing, and only it posts a cancel — the
749
+ * other two leave the host to its own timeout.
750
+ */
751
+ private refuse(
752
+ error: unknown,
753
+ lease: CallLease,
754
+ effectId: string,
755
+ cancellable: boolean,
756
+ ): ComputerError {
757
+ if (lease.callerAborted) {
758
+ if (cancellable) this.cancelQuietly(effectId);
759
+ return new ComputerError(
760
+ "aborted",
761
+ "The Computer effect was cancelled",
762
+ false,
763
+ { cause: error },
764
+ );
765
+ }
766
+ if (lease.timedOut) {
767
+ return new ComputerError(
768
+ "provider-unavailable",
769
+ "The Computer host did not answer within the effect's deadline",
770
+ true,
771
+ { cause: error },
772
+ );
773
+ }
774
+ return new ComputerError(
775
+ "provider-unavailable",
776
+ `The Computer host is unreachable: ${
777
+ error instanceof Error ? error.message : String(error)
778
+ }`,
779
+ true,
780
+ { cause: error },
781
+ );
782
+ }
783
+
784
+ /**
785
+ * Tells the host to kill a process this client has stopped listening to.
786
+ *
787
+ * Best-effort by construction: the caller has already given up, so a failed
788
+ * cancel must not become the error it sees. The host's own per-phase timeout
789
+ * is the backstop when this never arrives.
790
+ */
791
+ private cancelQuietly(effectId: string): void {
792
+ void this.cancel(effectId).catch(() => undefined);
793
+ }
794
+ }