@frockbot/computer-host-protocol 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,1297 @@
1
+ /**
2
+ * The versioned wire protocol between a Bot Durable Object and the shared
3
+ * Computer host of ADR 0004.
4
+ *
5
+ * "Cross-runtime communication uses narrow, versioned DTOs, and every inbound
6
+ * value is decoded at its seam." Both sides of the seam import this module and
7
+ * neither owns a second copy: the Durable Object encodes a request here, the
8
+ * Node container decodes it here, and the container's answer travels back
9
+ * through the same decoders.
10
+ *
11
+ * Every request carries the same envelope — `version`, the `effectId` the Bot
12
+ * Durable Object recorded before it called, the `identity` whose Computer this
13
+ * is (ADR 0012: a Computer is keyed by User and by nothing else), the `tenant`
14
+ * Bot making the call, and an opaque `credentialRef`. The reference is
15
+ * resolved on the host and never carries credential material: "Secrets remain
16
+ * server-side and cross interfaces only as opaque references when necessary."
17
+ */
18
+
19
+ export const COMPUTER_HOST_PROTOCOL_VERSION = 1;
20
+
21
+ /** Header carrying the shared secret between the two Workers and the container. */
22
+ export const COMPUTER_HOST_TOKEN_HEADER = "x-frockbot-host-token";
23
+
24
+ /** NDJSON media type used by a streaming exec response. */
25
+ export const COMPUTER_HOST_STREAM_MEDIA_TYPE = "application/x-ndjson";
26
+
27
+ /**
28
+ * Bounds every decoder enforces. They are declared rather than inlined so the
29
+ * container, the client, and their tests all refuse at the same size, and so a
30
+ * limit change is one edit at one seam.
31
+ */
32
+ export const COMPUTER_HOST_LIMITS = {
33
+ /** Identifiers: effect, user, Bot, viewer session, control owner. */
34
+ identifier: 200,
35
+ credentialRef: 256,
36
+ /** A script shipped over stdin. The 431 that killed argv delivery is why. */
37
+ script: 1_000_000,
38
+ /** An absolute path on the Computer. */
39
+ path: 4_096,
40
+ /** Base64 payload for a file write or exec stdin, encoded length. */
41
+ payloadBase64: 16 * 1_024 * 1_024,
42
+ /** Environment variables handed to one exec. */
43
+ envEntries: 64,
44
+ envKey: 256,
45
+ envValue: 32_768,
46
+ /** Directory listing page. */
47
+ listEntries: 2_000,
48
+ /** The longest an exec may run, and the ceiling a request may ask for. */
49
+ execTimeoutMs: 600_000,
50
+ /** The most output one exec may return, and the ceiling a request may ask. */
51
+ maxOutputBytes: 4 * 1_024 * 1_024,
52
+ /** Human-control lease age a request may ask for, in seconds. */
53
+ controlMaxAgeSeconds: 3_600,
54
+ /** Declared service name. */
55
+ serviceName: 128,
56
+ /** Failure text carried on a frame or a problem response. */
57
+ message: 2_048,
58
+ /** The whole JSON request body. */
59
+ requestBytes: 32 * 1_024 * 1_024,
60
+ } as const;
61
+
62
+ export type ComputerHostErrorCodeV1 =
63
+ | "invalid-request"
64
+ | "not-authorized"
65
+ | "not-found"
66
+ | "conflict"
67
+ | "limit-exceeded"
68
+ | "human-control-active"
69
+ | "computer-updating"
70
+ | "aborted"
71
+ | "timeout"
72
+ | "provider-unavailable"
73
+ | "provider-failure";
74
+
75
+ const ERROR_CODES: readonly ComputerHostErrorCodeV1[] = [
76
+ "invalid-request",
77
+ "not-authorized",
78
+ "not-found",
79
+ "conflict",
80
+ "limit-exceeded",
81
+ "human-control-active",
82
+ "computer-updating",
83
+ "aborted",
84
+ "timeout",
85
+ "provider-unavailable",
86
+ "provider-failure",
87
+ ];
88
+
89
+ export interface ComputerHostIdentityV1 {
90
+ userId: string;
91
+ }
92
+
93
+ export interface ComputerHostTenantV1 {
94
+ botId: string;
95
+ }
96
+
97
+ export interface ComputerHostEnvelopeV1 {
98
+ version: typeof COMPUTER_HOST_PROTOCOL_VERSION;
99
+ effectId: string;
100
+ identity: ComputerHostIdentityV1;
101
+ tenant: ComputerHostTenantV1;
102
+ credentialRef: string;
103
+ }
104
+
105
+ export interface ComputerHostOpenOperationV1 {
106
+ kind: "open";
107
+ }
108
+
109
+ export interface ComputerHostExecOperationV1 {
110
+ kind: "exec";
111
+ /** Shell source delivered on the command's stdin. Never on its argv. */
112
+ script: string;
113
+ cwd?: string;
114
+ env?: Record<string, string>;
115
+ /** Extra stdin appended after the script, base64. */
116
+ stdinBase64?: string;
117
+ timeoutMs: number;
118
+ maxOutputBytes: number;
119
+ /** True for an NDJSON frame stream, false for one buffered answer. */
120
+ stream: boolean;
121
+ }
122
+
123
+ export interface ComputerHostFileReadOperationV1 {
124
+ kind: "file/read";
125
+ path: string;
126
+ }
127
+
128
+ export interface ComputerHostFileWriteOperationV1 {
129
+ kind: "file/write";
130
+ path: string;
131
+ bytesBase64: string;
132
+ mode?: number;
133
+ }
134
+
135
+ export interface ComputerHostFileListOperationV1 {
136
+ kind: "file/list";
137
+ path: string;
138
+ recursive: boolean;
139
+ }
140
+
141
+ export interface ComputerHostFileStatOperationV1 {
142
+ kind: "file/stat";
143
+ path: string;
144
+ }
145
+
146
+ export interface ComputerHostFileDeleteOperationV1 {
147
+ kind: "file/delete";
148
+ path: string;
149
+ recursive: boolean;
150
+ }
151
+
152
+ export type ComputerHostControlActionV1 = "acquire" | "renew" | "release";
153
+
154
+ /**
155
+ * What one control lease covers.
156
+ *
157
+ * `bot` is the original scope and the default: the human-takeover lease on one
158
+ * tenant's own desktop slot. `desktop-gui` is User-wide — one Computer serves
159
+ * all of a User's Bots and there is one screen on it, so a lease that
160
+ * serializes GUI work has to be held against the box, not against a tenant
161
+ * directory (ADR 0017, `computerUse`: "only one may run at a time because the
162
+ * screen is shared").
163
+ */
164
+ export type ComputerHostControlScopeV1 = "bot" | "desktop-gui";
165
+
166
+ export interface ComputerHostControlOperationV1 {
167
+ kind: "control";
168
+ action: ComputerHostControlActionV1;
169
+ ownerId: string;
170
+ maxAgeSeconds: number;
171
+ /** Absent ⇒ `bot`, the per-tenant takeover lease every caller held before. */
172
+ scope?: ComputerHostControlScopeV1;
173
+ }
174
+
175
+ export interface ComputerHostViewerOperationV1 {
176
+ kind: "viewer";
177
+ action: "open" | "renew" | "revoke";
178
+ sessionId?: string;
179
+ }
180
+
181
+ export interface ComputerHostServiceOperationV1 {
182
+ kind: "service";
183
+ name: string;
184
+ }
185
+
186
+ /**
187
+ * Cancels the effect the envelope names. There is no second identifier: the
188
+ * envelope's `effectId` is the effect being cancelled, so a cancel cannot
189
+ * disagree with itself about what it is cancelling.
190
+ */
191
+ export interface ComputerHostCancelOperationV1 {
192
+ kind: "cancel";
193
+ }
194
+
195
+ export type ComputerHostOperationV1 =
196
+ | ComputerHostOpenOperationV1
197
+ | ComputerHostExecOperationV1
198
+ | ComputerHostFileReadOperationV1
199
+ | ComputerHostFileWriteOperationV1
200
+ | ComputerHostFileListOperationV1
201
+ | ComputerHostFileStatOperationV1
202
+ | ComputerHostFileDeleteOperationV1
203
+ | ComputerHostControlOperationV1
204
+ | ComputerHostViewerOperationV1
205
+ | ComputerHostServiceOperationV1
206
+ | ComputerHostCancelOperationV1;
207
+
208
+ export interface ComputerHostRequestV1 extends ComputerHostEnvelopeV1 {
209
+ operation: ComputerHostOperationV1;
210
+ }
211
+
212
+ export type ComputerHostOperationKindV1 = ComputerHostOperationV1["kind"];
213
+
214
+ /** The route each operation is posted to. */
215
+ export const COMPUTER_HOST_ROUTES = {
216
+ open: "/v1/computer/open",
217
+ exec: "/v1/computer/exec",
218
+ "file/read": "/v1/computer/file/read",
219
+ "file/write": "/v1/computer/file/write",
220
+ "file/list": "/v1/computer/file/list",
221
+ "file/stat": "/v1/computer/file/stat",
222
+ "file/delete": "/v1/computer/file/delete",
223
+ control: "/v1/computer/control",
224
+ viewer: "/v1/computer/viewer",
225
+ service: "/v1/computer/service",
226
+ cancel: "/v1/computer/cancel",
227
+ } as const satisfies Record<ComputerHostOperationKindV1, string>;
228
+
229
+ const KIND_BY_ROUTE = new Map<string, ComputerHostOperationKindV1>(
230
+ Object.entries(COMPUTER_HOST_ROUTES).map(([kind, route]) => [
231
+ route,
232
+ kind as ComputerHostOperationKindV1,
233
+ ]),
234
+ );
235
+
236
+ /** The operation a pathname addresses, or `undefined` for an unknown route. */
237
+ export function computerHostOperationKindV1(
238
+ pathname: string,
239
+ ): ComputerHostOperationKindV1 | undefined {
240
+ return KIND_BY_ROUTE.get(pathname);
241
+ }
242
+
243
+ // --- responses -------------------------------------------------------------
244
+
245
+ /**
246
+ * How far provisioning a cold Computer got, and how it ended.
247
+ *
248
+ * Provisioning a Computer installs a desktop stack and is quiet for minutes
249
+ * (ADR 0004), so the phase is on the wire: a client that would otherwise show
250
+ * nothing at all can say "installing the desktop packages (2/5)", and a
251
+ * failure names the phase it failed in rather than the whole install.
252
+ */
253
+ export interface ComputerHostProvisioningV1 {
254
+ /** Whether this run creates a Computer or updates its runtime in place. */
255
+ kind: "provision" | "update";
256
+ /** Machine name of the phase reached: a declared phase, or `ready`. */
257
+ phase: string;
258
+ /** The same phase in words, for a client to show. */
259
+ label: string;
260
+ /** 1-based position of `phase`, or 0 before the first one begins. */
261
+ index: number;
262
+ /** How many phases a full provisioning run has. */
263
+ total: number;
264
+ status: "complete" | "running" | "failed";
265
+ /**
266
+ * True when this run completed a Computer that was already part-provisioned
267
+ * — a marker file said so, and the finished phases were not run again.
268
+ */
269
+ resumed: boolean;
270
+ }
271
+
272
+ export interface ComputerHostOpenResultV1 {
273
+ version: typeof COMPUTER_HOST_PROTOCOL_VERSION;
274
+ effectId: string;
275
+ spriteName: string;
276
+ /** The tenant's durable directory, relative to the Workspace home. */
277
+ directory: string;
278
+ /** The tenant's X display, when the Computer allocated one. */
279
+ display?: string;
280
+ /** The Computer's provisioning generation, bumped on every reprovision. */
281
+ generation: number;
282
+ /**
283
+ * Present when this `open` provisioned or resumed the Computer. Absent when
284
+ * it adopted one that was already provisioned, which is the common case.
285
+ */
286
+ provisioning?: ComputerHostProvisioningV1;
287
+ }
288
+
289
+ export interface ComputerHostExecResultV1 {
290
+ version: typeof COMPUTER_HOST_PROTOCOL_VERSION;
291
+ effectId: string;
292
+ exitCode: number | null;
293
+ signal?: string;
294
+ stdoutBase64: string;
295
+ stderrBase64: string;
296
+ outputTruncated: boolean;
297
+ }
298
+
299
+ export type ComputerHostFileKindV1 = "file" | "directory" | "other";
300
+
301
+ export interface ComputerHostFileEntryV1 {
302
+ path: string;
303
+ kind: ComputerHostFileKindV1;
304
+ size: number;
305
+ /** POSIX mode bits, masked to the low twelve. */
306
+ mode: number;
307
+ /** ISO-8601 modification time, absent when the Computer reported none. */
308
+ modifiedAt?: string;
309
+ }
310
+
311
+ export interface ComputerHostFileReadResultV1 {
312
+ version: typeof COMPUTER_HOST_PROTOCOL_VERSION;
313
+ effectId: string;
314
+ entry: ComputerHostFileEntryV1;
315
+ bytesBase64: string;
316
+ }
317
+
318
+ export interface ComputerHostFileStatResultV1 {
319
+ version: typeof COMPUTER_HOST_PROTOCOL_VERSION;
320
+ effectId: string;
321
+ entry: ComputerHostFileEntryV1;
322
+ }
323
+
324
+ export interface ComputerHostFileListResultV1 {
325
+ version: typeof COMPUTER_HOST_PROTOCOL_VERSION;
326
+ effectId: string;
327
+ entries: ComputerHostFileEntryV1[];
328
+ truncated: boolean;
329
+ }
330
+
331
+ export interface ComputerHostFileWriteResultV1 {
332
+ version: typeof COMPUTER_HOST_PROTOCOL_VERSION;
333
+ effectId: string;
334
+ entry: ComputerHostFileEntryV1;
335
+ }
336
+
337
+ export interface ComputerHostFileDeleteResultV1 {
338
+ version: typeof COMPUTER_HOST_PROTOCOL_VERSION;
339
+ effectId: string;
340
+ path: string;
341
+ deleted: boolean;
342
+ }
343
+
344
+ export interface ComputerHostControlResultV1 {
345
+ version: typeof COMPUTER_HOST_PROTOCOL_VERSION;
346
+ effectId: string;
347
+ action: ComputerHostControlActionV1;
348
+ ownerId: string;
349
+ /** ISO-8601 expiry of the lease, absent after a release. */
350
+ expiresAt?: string;
351
+ }
352
+
353
+ export interface ComputerHostViewerResultV1 {
354
+ version: typeof COMPUTER_HOST_PROTOCOL_VERSION;
355
+ effectId: string;
356
+ /** Absent after a revoke. */
357
+ session?: { id: string; url: string; expiresAt?: string };
358
+ }
359
+
360
+ export interface ComputerHostServiceResultV1 {
361
+ version: typeof COMPUTER_HOST_PROTOCOL_VERSION;
362
+ effectId: string;
363
+ name: string;
364
+ status: "running" | "unavailable";
365
+ }
366
+
367
+ export interface ComputerHostCancelResultV1 {
368
+ version: typeof COMPUTER_HOST_PROTOCOL_VERSION;
369
+ effectId: string;
370
+ /** False when the host held no in-flight effect under that identity. */
371
+ cancelled: boolean;
372
+ }
373
+
374
+ /**
375
+ * A failure the host declares rather than throws. It is the body of every
376
+ * non-2xx answer and of an `error` exec frame, so a caller reads one shape.
377
+ */
378
+ export interface ComputerHostProblemV1 {
379
+ version: typeof COMPUTER_HOST_PROTOCOL_VERSION;
380
+ code: ComputerHostErrorCodeV1;
381
+ message: string;
382
+ retryable: boolean;
383
+ }
384
+
385
+ export type ComputerHostExecFrameV1 =
386
+ | { type: "stdout"; dataBase64: string }
387
+ | { type: "stderr"; dataBase64: string }
388
+ | {
389
+ type: "exit";
390
+ exitCode: number | null;
391
+ signal?: string;
392
+ outputTruncated: boolean;
393
+ }
394
+ | {
395
+ type: "error";
396
+ code: ComputerHostErrorCodeV1;
397
+ message: string;
398
+ retryable: boolean;
399
+ };
400
+
401
+ // --- primitive decoders ----------------------------------------------------
402
+
403
+ export class ComputerHostDecodeError extends Error {
404
+ // Plain fields rather than parameter properties: this module is loaded by
405
+ // Node's type stripping inside the container, which erases types and
406
+ // transforms nothing.
407
+ readonly code: ComputerHostErrorCodeV1;
408
+
409
+ constructor(
410
+ message: string,
411
+ code: ComputerHostErrorCodeV1 = "invalid-request",
412
+ ) {
413
+ super(message);
414
+ this.name = "ComputerHostDecodeError";
415
+ this.code = code;
416
+ }
417
+ }
418
+
419
+ function fail(message: string): never {
420
+ throw new ComputerHostDecodeError(message);
421
+ }
422
+
423
+ function object(input: unknown, label: string): Record<string, unknown> {
424
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
425
+ fail(`${label} must be an object`);
426
+ }
427
+ return input as Record<string, unknown>;
428
+ }
429
+
430
+ /** Refuses a field the schema does not declare, so a caller cannot smuggle one. */
431
+ function exactly(
432
+ input: Record<string, unknown>,
433
+ allowed: readonly string[],
434
+ label: string,
435
+ ): void {
436
+ for (const key of Object.keys(input)) {
437
+ if (!allowed.includes(key)) fail(`${label} has an unknown field: ${key}`);
438
+ }
439
+ }
440
+
441
+ function boundedString(
442
+ input: unknown,
443
+ maximumLength: number,
444
+ label: string,
445
+ ): string {
446
+ if (typeof input !== "string" || input.length === 0) {
447
+ fail(`${label} must be a non-empty string`);
448
+ }
449
+ const value = input as string;
450
+ if (value.length > maximumLength) {
451
+ throw new ComputerHostDecodeError(
452
+ `${label} exceeds ${maximumLength} characters`,
453
+ "limit-exceeded",
454
+ );
455
+ }
456
+ return value;
457
+ }
458
+
459
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:@-]*$/;
460
+
461
+ function identifier(input: unknown, label: string): string {
462
+ const value = boundedString(input, COMPUTER_HOST_LIMITS.identifier, label);
463
+ if (!IDENTIFIER.test(value)) fail(`${label} is not a valid identifier`);
464
+ return value;
465
+ }
466
+
467
+ function boundedInteger(
468
+ input: unknown,
469
+ minimum: number,
470
+ maximum: number,
471
+ label: string,
472
+ ): number {
473
+ if (!Number.isSafeInteger(input)) fail(`${label} must be an integer`);
474
+ const value = input as number;
475
+ if (value < minimum || value > maximum) {
476
+ throw new ComputerHostDecodeError(
477
+ `${label} must be between ${minimum} and ${maximum}`,
478
+ "limit-exceeded",
479
+ );
480
+ }
481
+ return value;
482
+ }
483
+
484
+ function boolean(input: unknown, label: string): boolean {
485
+ if (typeof input !== "boolean") fail(`${label} must be a boolean`);
486
+ return input;
487
+ }
488
+
489
+ const BASE64 =
490
+ /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
491
+
492
+ /** Base64 with a declared ceiling. An empty payload is legal; a malformed one is not. */
493
+ export function decodeBase64FieldV1(
494
+ input: unknown,
495
+ label: string,
496
+ maximumLength = COMPUTER_HOST_LIMITS.payloadBase64,
497
+ ): string {
498
+ if (typeof input !== "string") fail(`${label} must be a base64 string`);
499
+ const value = input as string;
500
+ if (value.length > maximumLength) {
501
+ throw new ComputerHostDecodeError(
502
+ `${label} exceeds ${maximumLength} encoded bytes`,
503
+ "limit-exceeded",
504
+ );
505
+ }
506
+ if (!BASE64.test(value)) fail(`${label} is not valid base64`);
507
+ return value;
508
+ }
509
+
510
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
511
+
512
+ /**
513
+ * An absolute, normalized path on the Computer. Relative paths, traversal, and
514
+ * control characters are refused here rather than on the Sprite: the mount
515
+ * path never leaves the provider Package, and a path that reaches the host is
516
+ * already resolved.
517
+ */
518
+ export function decodeComputerPathV1(input: unknown, label = "path"): string {
519
+ const value = boundedString(input, COMPUTER_HOST_LIMITS.path, label);
520
+ const segments = value.split("/");
521
+ if (
522
+ !value.startsWith("/") ||
523
+ value.includes("//") ||
524
+ value.includes("\\") ||
525
+ CONTROL_CHARACTERS.test(value) ||
526
+ segments.some((segment, index) =>
527
+ index === 0 ? segment !== "" : segment === "." || segment === "..",
528
+ ) ||
529
+ (value.length > 1 && value.endsWith("/"))
530
+ ) {
531
+ fail(`${label} must be an absolute normalized Computer path`);
532
+ }
533
+ return value;
534
+ }
535
+
536
+ function environment(input: unknown): Record<string, string> {
537
+ const value = object(input, "Computer exec env");
538
+ const keys = Object.keys(value);
539
+ if (keys.length > COMPUTER_HOST_LIMITS.envEntries) {
540
+ throw new ComputerHostDecodeError(
541
+ `Computer exec env exceeds ${COMPUTER_HOST_LIMITS.envEntries} entries`,
542
+ "limit-exceeded",
543
+ );
544
+ }
545
+ const decoded: Record<string, string> = {};
546
+ for (const key of keys) {
547
+ if (
548
+ key.length > COMPUTER_HOST_LIMITS.envKey ||
549
+ !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)
550
+ ) {
551
+ fail(`Computer exec env name is invalid: ${key.slice(0, 64)}`);
552
+ }
553
+ const item = value[key];
554
+ if (typeof item !== "string") {
555
+ fail(`Computer exec env ${key} must be a string`);
556
+ }
557
+ if ((item as string).length > COMPUTER_HOST_LIMITS.envValue) {
558
+ throw new ComputerHostDecodeError(
559
+ `Computer exec env ${key} exceeds ${COMPUTER_HOST_LIMITS.envValue} characters`,
560
+ "limit-exceeded",
561
+ );
562
+ }
563
+ decoded[key] = item as string;
564
+ }
565
+ return decoded;
566
+ }
567
+
568
+ // --- request decoding ------------------------------------------------------
569
+
570
+ const ENVELOPE_FIELDS = [
571
+ "version",
572
+ "effectId",
573
+ "identity",
574
+ "tenant",
575
+ "credentialRef",
576
+ ] as const;
577
+
578
+ function decodeEnvelope(
579
+ value: Record<string, unknown>,
580
+ extra: readonly string[],
581
+ ): ComputerHostEnvelopeV1 {
582
+ exactly(value, [...ENVELOPE_FIELDS, ...extra], "Computer host request");
583
+ if (value.version !== COMPUTER_HOST_PROTOCOL_VERSION) {
584
+ fail("Computer host request version is not 1");
585
+ }
586
+ const identity = object(value.identity, "Computer identity");
587
+ exactly(identity, ["userId"], "Computer identity");
588
+ const tenant = object(value.tenant, "Computer tenant");
589
+ exactly(tenant, ["botId"], "Computer tenant");
590
+ return {
591
+ version: COMPUTER_HOST_PROTOCOL_VERSION,
592
+ effectId: identifier(value.effectId, "Computer effect id"),
593
+ identity: { userId: identifier(identity.userId, "Computer user id") },
594
+ tenant: { botId: identifier(tenant.botId, "Computer Bot id") },
595
+ credentialRef: boundedString(
596
+ value.credentialRef,
597
+ COMPUTER_HOST_LIMITS.credentialRef,
598
+ "Computer credential reference",
599
+ ),
600
+ };
601
+ }
602
+
603
+ function decodeOperation(
604
+ kind: ComputerHostOperationKindV1,
605
+ value: Record<string, unknown>,
606
+ ): ComputerHostOperationV1 {
607
+ switch (kind) {
608
+ case "open":
609
+ case "cancel":
610
+ return { kind };
611
+ case "exec":
612
+ return {
613
+ kind,
614
+ script: boundedString(
615
+ value.script,
616
+ COMPUTER_HOST_LIMITS.script,
617
+ "Computer exec script",
618
+ ),
619
+ ...(value.cwd === undefined
620
+ ? {}
621
+ : { cwd: decodeComputerPathV1(value.cwd, "Computer exec cwd") }),
622
+ ...(value.env === undefined ? {} : { env: environment(value.env) }),
623
+ ...(value.stdinBase64 === undefined
624
+ ? {}
625
+ : {
626
+ stdinBase64: decodeBase64FieldV1(
627
+ value.stdinBase64,
628
+ "Computer exec stdin",
629
+ ),
630
+ }),
631
+ timeoutMs: boundedInteger(
632
+ value.timeoutMs,
633
+ 1,
634
+ COMPUTER_HOST_LIMITS.execTimeoutMs,
635
+ "Computer exec timeout",
636
+ ),
637
+ maxOutputBytes: boundedInteger(
638
+ value.maxOutputBytes,
639
+ 1,
640
+ COMPUTER_HOST_LIMITS.maxOutputBytes,
641
+ "Computer exec output limit",
642
+ ),
643
+ stream: boolean(value.stream, "Computer exec stream"),
644
+ };
645
+ case "file/read":
646
+ case "file/stat":
647
+ return { kind, path: decodeComputerPathV1(value.path) };
648
+ case "file/write":
649
+ return {
650
+ kind,
651
+ path: decodeComputerPathV1(value.path),
652
+ bytesBase64: decodeBase64FieldV1(
653
+ value.bytesBase64,
654
+ "Computer file bytes",
655
+ ),
656
+ ...(value.mode === undefined
657
+ ? {}
658
+ : {
659
+ mode: boundedInteger(value.mode, 0, 0o7777, "Computer file mode"),
660
+ }),
661
+ };
662
+ case "file/list":
663
+ case "file/delete":
664
+ return {
665
+ kind,
666
+ path: decodeComputerPathV1(value.path),
667
+ recursive:
668
+ value.recursive === undefined
669
+ ? false
670
+ : boolean(value.recursive, "Computer recursive flag"),
671
+ };
672
+ case "control": {
673
+ const action = value.action;
674
+ if (action !== "acquire" && action !== "renew" && action !== "release") {
675
+ fail("Computer control action is invalid");
676
+ }
677
+ const scope = value.scope;
678
+ if (scope !== undefined && scope !== "bot" && scope !== "desktop-gui") {
679
+ fail("Computer control scope is invalid");
680
+ }
681
+ return {
682
+ kind,
683
+ action,
684
+ ownerId: identifier(value.ownerId, "Computer control owner"),
685
+ maxAgeSeconds: boundedInteger(
686
+ value.maxAgeSeconds,
687
+ 1,
688
+ COMPUTER_HOST_LIMITS.controlMaxAgeSeconds,
689
+ "Computer control lease age",
690
+ ),
691
+ ...(scope === undefined ? {} : { scope }),
692
+ };
693
+ }
694
+ case "viewer": {
695
+ const action = value.action;
696
+ if (action !== "open" && action !== "renew" && action !== "revoke") {
697
+ fail("Computer viewer action is invalid");
698
+ }
699
+ if (action !== "open" && value.sessionId === undefined) {
700
+ fail(`Computer viewer ${action} requires a session id`);
701
+ }
702
+ return {
703
+ kind,
704
+ action,
705
+ ...(value.sessionId === undefined
706
+ ? {}
707
+ : {
708
+ sessionId: identifier(value.sessionId, "Computer viewer session"),
709
+ }),
710
+ };
711
+ }
712
+ case "service":
713
+ return {
714
+ kind,
715
+ name: boundedString(
716
+ value.name,
717
+ COMPUTER_HOST_LIMITS.serviceName,
718
+ "Computer service name",
719
+ ),
720
+ };
721
+ }
722
+ }
723
+
724
+ const OPERATION_FIELDS: Record<ComputerHostOperationKindV1, readonly string[]> =
725
+ {
726
+ open: [],
727
+ exec: [
728
+ "script",
729
+ "cwd",
730
+ "env",
731
+ "stdinBase64",
732
+ "timeoutMs",
733
+ "maxOutputBytes",
734
+ "stream",
735
+ ],
736
+ "file/read": ["path"],
737
+ "file/write": ["path", "bytesBase64", "mode"],
738
+ "file/list": ["path", "recursive"],
739
+ "file/stat": ["path"],
740
+ "file/delete": ["path", "recursive"],
741
+ control: ["action", "ownerId", "maxAgeSeconds", "scope"],
742
+ viewer: ["action", "sessionId"],
743
+ service: ["name"],
744
+ cancel: [],
745
+ };
746
+
747
+ /** Decodes one request body already known to address `kind`. */
748
+ export function decodeComputerHostRequestV1(
749
+ kind: ComputerHostOperationKindV1,
750
+ input: unknown,
751
+ ): ComputerHostRequestV1 {
752
+ const value = object(input, "Computer host request");
753
+ const envelope = decodeEnvelope(value, OPERATION_FIELDS[kind]);
754
+ return { ...envelope, operation: decodeOperation(kind, value) };
755
+ }
756
+
757
+ /** Encodes one request as the body posted to `COMPUTER_HOST_ROUTES[kind]`. */
758
+ export function encodeComputerHostRequestV1(
759
+ request: ComputerHostRequestV1,
760
+ ): Record<string, unknown> {
761
+ const { operation, ...envelope } = request;
762
+ const { kind, ...body } = operation;
763
+ void kind;
764
+ return { ...envelope, ...body };
765
+ }
766
+
767
+ export type ComputerHostDecodedRequestV1 =
768
+ | { ok: true; value: ComputerHostRequestV1 }
769
+ | { ok: false; response: Response };
770
+
771
+ /**
772
+ * Decodes an inbound HTTP request at the container's seam: route, method, body
773
+ * size, JSON, then the DTO. Every refusal is a `problem()` rather than an
774
+ * exception, so the container's handler has one shape to return.
775
+ */
776
+ export async function decodeComputerHostHttpRequestV1(
777
+ request: Request,
778
+ ): Promise<ComputerHostDecodedRequestV1> {
779
+ let pathname: string;
780
+ try {
781
+ pathname = new URL(request.url).pathname;
782
+ } catch {
783
+ return {
784
+ ok: false,
785
+ response: problem(400, "invalid-request", "invalid-url"),
786
+ };
787
+ }
788
+ const kind = computerHostOperationKindV1(pathname);
789
+ if (!kind) {
790
+ return {
791
+ ok: false,
792
+ response: problem(404, "not-found", "no such Computer host route"),
793
+ };
794
+ }
795
+ if (request.method !== "POST") {
796
+ return {
797
+ ok: false,
798
+ response: problem(
799
+ 405,
800
+ "invalid-request",
801
+ "Computer host routes accept POST",
802
+ ),
803
+ };
804
+ }
805
+ let text: string;
806
+ try {
807
+ text = await request.text();
808
+ } catch {
809
+ return {
810
+ ok: false,
811
+ response: problem(400, "invalid-request", "unreadable body"),
812
+ };
813
+ }
814
+ if (text.length > COMPUTER_HOST_LIMITS.requestBytes) {
815
+ return {
816
+ ok: false,
817
+ response: problem(413, "limit-exceeded", "request body too large"),
818
+ };
819
+ }
820
+ let body: unknown;
821
+ try {
822
+ body = JSON.parse(text);
823
+ } catch {
824
+ return {
825
+ ok: false,
826
+ response: problem(400, "invalid-request", "body is not JSON"),
827
+ };
828
+ }
829
+ try {
830
+ return { ok: true, value: decodeComputerHostRequestV1(kind, body) };
831
+ } catch (error) {
832
+ const code =
833
+ error instanceof ComputerHostDecodeError ? error.code : "invalid-request";
834
+ return {
835
+ ok: false,
836
+ response: problem(
837
+ code === "limit-exceeded" ? 413 : 400,
838
+ code,
839
+ error instanceof Error ? error.message : "invalid request",
840
+ ),
841
+ };
842
+ }
843
+ }
844
+
845
+ // --- response encoding and decoding ---------------------------------------
846
+
847
+ export function computerHostProblemV1(
848
+ code: ComputerHostErrorCodeV1,
849
+ message: string,
850
+ retryable = code === "provider-unavailable" ||
851
+ code === "limit-exceeded" ||
852
+ code === "computer-updating",
853
+ ): ComputerHostProblemV1 {
854
+ return {
855
+ version: COMPUTER_HOST_PROTOCOL_VERSION,
856
+ code,
857
+ message: message.slice(0, COMPUTER_HOST_LIMITS.message),
858
+ retryable,
859
+ };
860
+ }
861
+
862
+ /** The one failure shape the host returns, on every non-2xx answer. */
863
+ export function problem(
864
+ status: number,
865
+ code: ComputerHostErrorCodeV1,
866
+ message: string,
867
+ retryable = code === "provider-unavailable" ||
868
+ code === "limit-exceeded" ||
869
+ code === "computer-updating",
870
+ ): Response {
871
+ return Response.json(computerHostProblemV1(code, message, retryable), {
872
+ status,
873
+ });
874
+ }
875
+
876
+ export function decodeComputerHostProblemV1(
877
+ input: unknown,
878
+ ): ComputerHostProblemV1 {
879
+ const value = object(input, "Computer host problem");
880
+ exactly(
881
+ value,
882
+ ["version", "code", "message", "retryable"],
883
+ "Computer host problem",
884
+ );
885
+ if (value.version !== COMPUTER_HOST_PROTOCOL_VERSION) {
886
+ fail("Computer host problem version is not 1");
887
+ }
888
+ if (!ERROR_CODES.includes(value.code as ComputerHostErrorCodeV1)) {
889
+ fail("Computer host problem code is invalid");
890
+ }
891
+ return {
892
+ version: COMPUTER_HOST_PROTOCOL_VERSION,
893
+ code: value.code as ComputerHostErrorCodeV1,
894
+ message: boundedString(
895
+ value.message,
896
+ COMPUTER_HOST_LIMITS.message,
897
+ "Computer host problem message",
898
+ ),
899
+ retryable: boolean(value.retryable, "Computer host problem retryable"),
900
+ };
901
+ }
902
+
903
+ function resultEnvelope(
904
+ input: unknown,
905
+ allowed: readonly string[],
906
+ label: string,
907
+ ): Record<string, unknown> {
908
+ const value = object(input, label);
909
+ exactly(value, ["version", "effectId", ...allowed], label);
910
+ if (value.version !== COMPUTER_HOST_PROTOCOL_VERSION) {
911
+ fail(`${label} version is not 1`);
912
+ }
913
+ identifier(value.effectId, `${label} effect id`);
914
+ return value;
915
+ }
916
+
917
+ export function decodeComputerHostOpenResultV1(
918
+ input: unknown,
919
+ ): ComputerHostOpenResultV1 {
920
+ const label = "Computer host open result";
921
+ const value = resultEnvelope(
922
+ input,
923
+ ["spriteName", "directory", "display", "generation", "provisioning"],
924
+ label,
925
+ );
926
+ return {
927
+ version: COMPUTER_HOST_PROTOCOL_VERSION,
928
+ effectId: value.effectId as string,
929
+ spriteName: identifier(value.spriteName, `${label} sprite name`),
930
+ directory: boundedString(
931
+ value.directory,
932
+ COMPUTER_HOST_LIMITS.path,
933
+ `${label} directory`,
934
+ ),
935
+ ...(value.display === undefined
936
+ ? {}
937
+ : { display: boundedString(value.display, 16, `${label} display`) }),
938
+ generation: boundedInteger(
939
+ value.generation,
940
+ 0,
941
+ Number.MAX_SAFE_INTEGER,
942
+ `${label} generation`,
943
+ ),
944
+ ...(value.provisioning === undefined
945
+ ? {}
946
+ : {
947
+ provisioning: decodeComputerHostProvisioningV1(
948
+ value.provisioning,
949
+ label,
950
+ ),
951
+ }),
952
+ };
953
+ }
954
+
955
+ const PROVISIONING_STATUSES = new Set(["complete", "running", "failed"]);
956
+
957
+ function decodeComputerHostProvisioningV1(
958
+ input: unknown,
959
+ label: string,
960
+ ): ComputerHostProvisioningV1 {
961
+ const value = object(input, `${label} provisioning`);
962
+ exactly(
963
+ value,
964
+ ["kind", "phase", "label", "index", "total", "status", "resumed"],
965
+ `${label} provisioning`,
966
+ );
967
+ if (!PROVISIONING_STATUSES.has(value.status as string)) {
968
+ fail(`${label} provisioning status is not a provisioning status`);
969
+ }
970
+ if (typeof value.resumed !== "boolean") {
971
+ fail(`${label} provisioning resumed must be a boolean`);
972
+ }
973
+ if (value.kind !== "provision" && value.kind !== "update") {
974
+ fail(`${label} provisioning kind is invalid`);
975
+ }
976
+ return {
977
+ kind: value.kind,
978
+ phase: identifier(value.phase, `${label} provisioning phase`),
979
+ label: boundedString(value.label, 200, `${label} provisioning label`),
980
+ index: boundedInteger(value.index, 0, 1_000, `${label} provisioning index`),
981
+ total: boundedInteger(value.total, 1, 1_000, `${label} provisioning total`),
982
+ status: value.status as ComputerHostProvisioningV1["status"],
983
+ resumed: value.resumed,
984
+ };
985
+ }
986
+
987
+ export function decodeComputerHostExecResultV1(
988
+ input: unknown,
989
+ ): ComputerHostExecResultV1 {
990
+ const label = "Computer host exec result";
991
+ const value = resultEnvelope(
992
+ input,
993
+ ["exitCode", "signal", "stdoutBase64", "stderrBase64", "outputTruncated"],
994
+ label,
995
+ );
996
+ return {
997
+ version: COMPUTER_HOST_PROTOCOL_VERSION,
998
+ effectId: value.effectId as string,
999
+ exitCode:
1000
+ value.exitCode === null
1001
+ ? null
1002
+ : boundedInteger(value.exitCode, -1, 255, `${label} exit code`),
1003
+ ...(value.signal === undefined
1004
+ ? {}
1005
+ : { signal: boundedString(value.signal, 32, `${label} signal`) }),
1006
+ stdoutBase64: decodeBase64FieldV1(value.stdoutBase64, `${label} stdout`),
1007
+ stderrBase64: decodeBase64FieldV1(value.stderrBase64, `${label} stderr`),
1008
+ outputTruncated: boolean(value.outputTruncated, `${label} truncation`),
1009
+ };
1010
+ }
1011
+
1012
+ function fileEntry(input: unknown, label: string): ComputerHostFileEntryV1 {
1013
+ const value = object(input, label);
1014
+ exactly(value, ["path", "kind", "size", "mode", "modifiedAt"], label);
1015
+ const kind = value.kind;
1016
+ if (kind !== "file" && kind !== "directory" && kind !== "other") {
1017
+ fail(`${label} kind is invalid`);
1018
+ }
1019
+ return {
1020
+ path: decodeComputerPathV1(value.path, `${label} path`),
1021
+ kind,
1022
+ size: boundedInteger(
1023
+ value.size,
1024
+ 0,
1025
+ Number.MAX_SAFE_INTEGER,
1026
+ `${label} size`,
1027
+ ),
1028
+ mode: boundedInteger(value.mode, 0, 0o7777, `${label} mode`),
1029
+ ...(value.modifiedAt === undefined
1030
+ ? {}
1031
+ : { modifiedAt: boundedString(value.modifiedAt, 64, `${label} time`) }),
1032
+ };
1033
+ }
1034
+
1035
+ export function decodeComputerHostFileReadResultV1(
1036
+ input: unknown,
1037
+ ): ComputerHostFileReadResultV1 {
1038
+ const label = "Computer host file read result";
1039
+ const value = resultEnvelope(input, ["entry", "bytesBase64"], label);
1040
+ return {
1041
+ version: COMPUTER_HOST_PROTOCOL_VERSION,
1042
+ effectId: value.effectId as string,
1043
+ entry: fileEntry(value.entry, `${label} entry`),
1044
+ bytesBase64: decodeBase64FieldV1(value.bytesBase64, `${label} bytes`),
1045
+ };
1046
+ }
1047
+
1048
+ export function decodeComputerHostFileStatResultV1(
1049
+ input: unknown,
1050
+ ): ComputerHostFileStatResultV1 {
1051
+ const label = "Computer host file stat result";
1052
+ const value = resultEnvelope(input, ["entry"], label);
1053
+ return {
1054
+ version: COMPUTER_HOST_PROTOCOL_VERSION,
1055
+ effectId: value.effectId as string,
1056
+ entry: fileEntry(value.entry, `${label} entry`),
1057
+ };
1058
+ }
1059
+
1060
+ export function decodeComputerHostFileWriteResultV1(
1061
+ input: unknown,
1062
+ ): ComputerHostFileWriteResultV1 {
1063
+ const label = "Computer host file write result";
1064
+ const value = resultEnvelope(input, ["entry"], label);
1065
+ return {
1066
+ version: COMPUTER_HOST_PROTOCOL_VERSION,
1067
+ effectId: value.effectId as string,
1068
+ entry: fileEntry(value.entry, `${label} entry`),
1069
+ };
1070
+ }
1071
+
1072
+ export function decodeComputerHostFileListResultV1(
1073
+ input: unknown,
1074
+ ): ComputerHostFileListResultV1 {
1075
+ const label = "Computer host file list result";
1076
+ const value = resultEnvelope(input, ["entries", "truncated"], label);
1077
+ if (!Array.isArray(value.entries)) fail(`${label} entries must be an array`);
1078
+ const entries = value.entries as unknown[];
1079
+ if (entries.length > COMPUTER_HOST_LIMITS.listEntries) {
1080
+ throw new ComputerHostDecodeError(
1081
+ `${label} exceeds ${COMPUTER_HOST_LIMITS.listEntries} entries`,
1082
+ "limit-exceeded",
1083
+ );
1084
+ }
1085
+ return {
1086
+ version: COMPUTER_HOST_PROTOCOL_VERSION,
1087
+ effectId: value.effectId as string,
1088
+ entries: entries.map((entry) => fileEntry(entry, `${label} entry`)),
1089
+ truncated: boolean(value.truncated, `${label} truncation`),
1090
+ };
1091
+ }
1092
+
1093
+ export function decodeComputerHostFileDeleteResultV1(
1094
+ input: unknown,
1095
+ ): ComputerHostFileDeleteResultV1 {
1096
+ const label = "Computer host file delete result";
1097
+ const value = resultEnvelope(input, ["path", "deleted"], label);
1098
+ return {
1099
+ version: COMPUTER_HOST_PROTOCOL_VERSION,
1100
+ effectId: value.effectId as string,
1101
+ path: decodeComputerPathV1(value.path, `${label} path`),
1102
+ deleted: boolean(value.deleted, `${label} deletion`),
1103
+ };
1104
+ }
1105
+
1106
+ export function decodeComputerHostControlResultV1(
1107
+ input: unknown,
1108
+ ): ComputerHostControlResultV1 {
1109
+ const label = "Computer host control result";
1110
+ const value = resultEnvelope(
1111
+ input,
1112
+ ["action", "ownerId", "expiresAt"],
1113
+ label,
1114
+ );
1115
+ const action = value.action;
1116
+ if (action !== "acquire" && action !== "renew" && action !== "release") {
1117
+ fail(`${label} action is invalid`);
1118
+ }
1119
+ return {
1120
+ version: COMPUTER_HOST_PROTOCOL_VERSION,
1121
+ effectId: value.effectId as string,
1122
+ action,
1123
+ ownerId: identifier(value.ownerId, `${label} owner`),
1124
+ ...(value.expiresAt === undefined
1125
+ ? {}
1126
+ : { expiresAt: boundedString(value.expiresAt, 64, `${label} expiry`) }),
1127
+ };
1128
+ }
1129
+
1130
+ export function decodeComputerHostViewerResultV1(
1131
+ input: unknown,
1132
+ ): ComputerHostViewerResultV1 {
1133
+ const label = "Computer host viewer result";
1134
+ const value = resultEnvelope(input, ["session"], label);
1135
+ if (value.session === undefined) {
1136
+ return {
1137
+ version: COMPUTER_HOST_PROTOCOL_VERSION,
1138
+ effectId: value.effectId as string,
1139
+ };
1140
+ }
1141
+ const session = object(value.session, `${label} session`);
1142
+ exactly(session, ["id", "url", "expiresAt"], `${label} session`);
1143
+ return {
1144
+ version: COMPUTER_HOST_PROTOCOL_VERSION,
1145
+ effectId: value.effectId as string,
1146
+ session: {
1147
+ id: identifier(session.id, `${label} session id`),
1148
+ url: boundedString(session.url, 4_096, `${label} session url`),
1149
+ ...(session.expiresAt === undefined
1150
+ ? {}
1151
+ : {
1152
+ expiresAt: boundedString(session.expiresAt, 64, `${label} expiry`),
1153
+ }),
1154
+ },
1155
+ };
1156
+ }
1157
+
1158
+ export function decodeComputerHostServiceResultV1(
1159
+ input: unknown,
1160
+ ): ComputerHostServiceResultV1 {
1161
+ const label = "Computer host service result";
1162
+ const value = resultEnvelope(input, ["name", "status"], label);
1163
+ if (value.status !== "running" && value.status !== "unavailable") {
1164
+ fail(`${label} status is invalid`);
1165
+ }
1166
+ return {
1167
+ version: COMPUTER_HOST_PROTOCOL_VERSION,
1168
+ effectId: value.effectId as string,
1169
+ name: boundedString(
1170
+ value.name,
1171
+ COMPUTER_HOST_LIMITS.serviceName,
1172
+ `${label} name`,
1173
+ ),
1174
+ status: value.status,
1175
+ };
1176
+ }
1177
+
1178
+ export function decodeComputerHostCancelResultV1(
1179
+ input: unknown,
1180
+ ): ComputerHostCancelResultV1 {
1181
+ const label = "Computer host cancel result";
1182
+ const value = resultEnvelope(input, ["cancelled"], label);
1183
+ return {
1184
+ version: COMPUTER_HOST_PROTOCOL_VERSION,
1185
+ effectId: value.effectId as string,
1186
+ cancelled: boolean(value.cancelled, `${label} cancellation`),
1187
+ };
1188
+ }
1189
+
1190
+ // --- exec frames -----------------------------------------------------------
1191
+
1192
+ /** One NDJSON line, newline included. */
1193
+ export function encodeComputerHostExecFrameV1(
1194
+ frame: ComputerHostExecFrameV1,
1195
+ ): string {
1196
+ return `${JSON.stringify(frame)}\n`;
1197
+ }
1198
+
1199
+ export function decodeComputerHostExecFrameV1(
1200
+ line: string,
1201
+ ): ComputerHostExecFrameV1 {
1202
+ let parsed: unknown;
1203
+ try {
1204
+ parsed = JSON.parse(line);
1205
+ } catch {
1206
+ fail("Computer exec frame is not JSON");
1207
+ }
1208
+ const value = object(parsed, "Computer exec frame");
1209
+ const type = value.type;
1210
+ if (type === "stdout" || type === "stderr") {
1211
+ exactly(value, ["type", "dataBase64"], "Computer exec frame");
1212
+ return {
1213
+ type,
1214
+ dataBase64: decodeBase64FieldV1(
1215
+ value.dataBase64,
1216
+ "Computer exec frame data",
1217
+ ),
1218
+ };
1219
+ }
1220
+ if (type === "exit") {
1221
+ exactly(
1222
+ value,
1223
+ ["type", "exitCode", "signal", "outputTruncated"],
1224
+ "Computer exec frame",
1225
+ );
1226
+ return {
1227
+ type,
1228
+ exitCode:
1229
+ value.exitCode === null
1230
+ ? null
1231
+ : boundedInteger(value.exitCode, -1, 255, "Computer exec exit code"),
1232
+ ...(value.signal === undefined
1233
+ ? {}
1234
+ : { signal: boundedString(value.signal, 32, "Computer exec signal") }),
1235
+ outputTruncated: boolean(
1236
+ value.outputTruncated,
1237
+ "Computer exec truncation",
1238
+ ),
1239
+ };
1240
+ }
1241
+ if (type === "error") {
1242
+ exactly(
1243
+ value,
1244
+ ["type", "code", "message", "retryable"],
1245
+ "Computer exec frame",
1246
+ );
1247
+ if (!ERROR_CODES.includes(value.code as ComputerHostErrorCodeV1)) {
1248
+ fail("Computer exec frame code is invalid");
1249
+ }
1250
+ return {
1251
+ type,
1252
+ code: value.code as ComputerHostErrorCodeV1,
1253
+ message: boundedString(
1254
+ value.message,
1255
+ COMPUTER_HOST_LIMITS.message,
1256
+ "Computer exec frame message",
1257
+ ),
1258
+ retryable: boolean(value.retryable, "Computer exec frame retryable"),
1259
+ };
1260
+ }
1261
+ return fail("Computer exec frame type is invalid");
1262
+ }
1263
+
1264
+ /**
1265
+ * Reassembles NDJSON frames from a byte stream whose chunk boundaries mean
1266
+ * nothing. This is the lesson of the framing incident recorded in ADR 0004: a
1267
+ * transport may split or coalesce anywhere, so a frame boundary is the newline
1268
+ * this decoder finds and never the chunk the transport delivered.
1269
+ */
1270
+ export class ComputerHostExecFrameReaderV1 {
1271
+ private buffer = "";
1272
+ private readonly decoder = new TextDecoder();
1273
+
1274
+ /** Frames completed by this chunk, in order. */
1275
+ push(chunk: Uint8Array | string): ComputerHostExecFrameV1[] {
1276
+ this.buffer +=
1277
+ typeof chunk === "string"
1278
+ ? chunk
1279
+ : this.decoder.decode(chunk, { stream: true });
1280
+ const frames: ComputerHostExecFrameV1[] = [];
1281
+ let newline = this.buffer.indexOf("\n");
1282
+ while (newline >= 0) {
1283
+ const line = this.buffer.slice(0, newline).trim();
1284
+ this.buffer = this.buffer.slice(newline + 1);
1285
+ if (line) frames.push(decodeComputerHostExecFrameV1(line));
1286
+ newline = this.buffer.indexOf("\n");
1287
+ }
1288
+ return frames;
1289
+ }
1290
+
1291
+ /** The trailing frame of a stream that ended without its final newline. */
1292
+ end(): ComputerHostExecFrameV1[] {
1293
+ const line = this.buffer.trim();
1294
+ this.buffer = "";
1295
+ return line ? [decodeComputerHostExecFrameV1(line)] : [];
1296
+ }
1297
+ }