@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,704 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ COMPUTER_HOST_LIMITS,
4
+ COMPUTER_HOST_ROUTES,
5
+ ComputerHostExecFrameReaderV1,
6
+ computerHostOperationKindV1,
7
+ computerHostProblemV1,
8
+ decodeBase64FieldV1,
9
+ decodeComputerHostCancelResultV1,
10
+ decodeComputerHostControlResultV1,
11
+ decodeComputerHostExecFrameV1,
12
+ decodeComputerHostExecResultV1,
13
+ decodeComputerHostFileDeleteResultV1,
14
+ decodeComputerHostFileListResultV1,
15
+ decodeComputerHostFileReadResultV1,
16
+ decodeComputerHostFileStatResultV1,
17
+ decodeComputerHostFileWriteResultV1,
18
+ decodeComputerHostHttpRequestV1,
19
+ decodeComputerHostOpenResultV1,
20
+ decodeComputerHostProblemV1,
21
+ decodeComputerHostRequestV1,
22
+ decodeComputerHostServiceResultV1,
23
+ decodeComputerHostViewerResultV1,
24
+ decodeComputerPathV1,
25
+ encodeComputerHostExecFrameV1,
26
+ encodeComputerHostRequestV1,
27
+ problem,
28
+ type ComputerHostOperationV1,
29
+ } from "./protocol.ts";
30
+
31
+ const envelope = {
32
+ version: 1 as const,
33
+ effectId: "effect-1",
34
+ identity: { userId: "user-1" },
35
+ tenant: { botId: "bot-1" },
36
+ credentialRef: "sprites:user:user-1",
37
+ };
38
+
39
+ function request(operation: ComputerHostOperationV1): Record<string, unknown> {
40
+ return encodeComputerHostRequestV1({ ...envelope, operation });
41
+ }
42
+
43
+ describe("routes", () => {
44
+ test("every operation kind has a route and resolves back to it", () => {
45
+ for (const [kind, route] of Object.entries(COMPUTER_HOST_ROUTES)) {
46
+ expect(computerHostOperationKindV1(route)).toBe(
47
+ kind as keyof typeof COMPUTER_HOST_ROUTES,
48
+ );
49
+ }
50
+ });
51
+
52
+ test("an unknown pathname resolves to no operation", () => {
53
+ expect(computerHostOperationKindV1("/v1/computer/smoke")).toBeUndefined();
54
+ });
55
+ });
56
+
57
+ describe("envelope", () => {
58
+ test("decodes the declared envelope on every operation", () => {
59
+ const decoded = decodeComputerHostRequestV1(
60
+ "open",
61
+ request({ kind: "open" }),
62
+ );
63
+ expect(decoded).toEqual({ ...envelope, operation: { kind: "open" } });
64
+ });
65
+
66
+ test("refuses a version other than 1", () => {
67
+ expect(() =>
68
+ decodeComputerHostRequestV1("open", {
69
+ ...request({ kind: "open" }),
70
+ version: 2,
71
+ }),
72
+ ).toThrow(/version is not 1/);
73
+ });
74
+
75
+ test("refuses a field the schema does not declare", () => {
76
+ expect(() =>
77
+ decodeComputerHostRequestV1("open", {
78
+ ...request({ kind: "open" }),
79
+ spriteName: "frockbot-elsewhere",
80
+ }),
81
+ ).toThrow(/unknown field/);
82
+ });
83
+
84
+ test("refuses a missing identity", () => {
85
+ const body = request({ kind: "open" }) as Record<string, unknown>;
86
+ delete body.identity;
87
+ expect(() => decodeComputerHostRequestV1("open", body)).toThrow(
88
+ /identity must be an object/,
89
+ );
90
+ });
91
+
92
+ test("refuses an identity carrying anything but a userId", () => {
93
+ expect(() =>
94
+ decodeComputerHostRequestV1("open", {
95
+ ...request({ kind: "open" }),
96
+ identity: { userId: "user-1", token: "leaked" },
97
+ }),
98
+ ).toThrow(/Computer identity has an unknown field/);
99
+ });
100
+
101
+ test("refuses an over-long credential reference", () => {
102
+ expect(() =>
103
+ decodeComputerHostRequestV1("open", {
104
+ ...request({ kind: "open" }),
105
+ credentialRef: "a".repeat(COMPUTER_HOST_LIMITS.credentialRef + 1),
106
+ }),
107
+ ).toThrow(/exceeds 256 characters/);
108
+ });
109
+ });
110
+
111
+ describe("exec", () => {
112
+ const exec: ComputerHostOperationV1 = {
113
+ kind: "exec",
114
+ script: "printf hello",
115
+ timeoutMs: 1_000,
116
+ maxOutputBytes: 4_096,
117
+ stream: true,
118
+ };
119
+
120
+ test("round-trips a script, cwd, env, and stdin", () => {
121
+ const operation: ComputerHostOperationV1 = {
122
+ ...exec,
123
+ cwd: "/home/box/agent-data",
124
+ env: { FROCKBOT_BOT_ID: "bot-1" },
125
+ stdinBase64: btoa("payload"),
126
+ };
127
+ expect(
128
+ decodeComputerHostRequestV1("exec", request(operation)).operation,
129
+ ).toEqual(operation);
130
+ });
131
+
132
+ test("accepts a script far larger than the argv limit that produced the 431", () => {
133
+ const script = "#".repeat(8_192);
134
+ const decoded = decodeComputerHostRequestV1(
135
+ "exec",
136
+ request({ ...exec, script }),
137
+ );
138
+ expect(decoded.operation).toMatchObject({ kind: "exec", script });
139
+ });
140
+
141
+ test("refuses a script beyond the declared ceiling", () => {
142
+ expect(() =>
143
+ decodeComputerHostRequestV1(
144
+ "exec",
145
+ request({
146
+ ...exec,
147
+ script: "x".repeat(COMPUTER_HOST_LIMITS.script + 1),
148
+ }),
149
+ ),
150
+ ).toThrow(/exceeds 1000000 characters/);
151
+ });
152
+
153
+ test("refuses a timeout beyond the ceiling", () => {
154
+ expect(() =>
155
+ decodeComputerHostRequestV1(
156
+ "exec",
157
+ request({ ...exec, timeoutMs: COMPUTER_HOST_LIMITS.execTimeoutMs + 1 }),
158
+ ),
159
+ ).toThrow(/timeout must be between/);
160
+ });
161
+
162
+ test("refuses an output limit beyond the ceiling", () => {
163
+ expect(() =>
164
+ decodeComputerHostRequestV1(
165
+ "exec",
166
+ request({
167
+ ...exec,
168
+ maxOutputBytes: COMPUTER_HOST_LIMITS.maxOutputBytes + 1,
169
+ }),
170
+ ),
171
+ ).toThrow(/output limit must be between/);
172
+ });
173
+
174
+ test("refuses a relative cwd", () => {
175
+ expect(() =>
176
+ decodeComputerHostRequestV1(
177
+ "exec",
178
+ request({ ...exec, cwd: "agent-data" }),
179
+ ),
180
+ ).toThrow(/absolute normalized Computer path/);
181
+ });
182
+
183
+ test("refuses an env name that is not a shell identifier", () => {
184
+ expect(() =>
185
+ decodeComputerHostRequestV1("exec", {
186
+ ...request(exec),
187
+ env: { "PATH;rm -rf /": "x" },
188
+ }),
189
+ ).toThrow(/env name is invalid/);
190
+ });
191
+
192
+ test("refuses more env entries than the declared ceiling", () => {
193
+ const env = Object.fromEntries(
194
+ Array.from(
195
+ { length: COMPUTER_HOST_LIMITS.envEntries + 1 },
196
+ (_value, index) => [`VAR_${index}`, "x"],
197
+ ),
198
+ );
199
+ expect(() =>
200
+ decodeComputerHostRequestV1("exec", { ...request(exec), env }),
201
+ ).toThrow(/env exceeds 64 entries/);
202
+ });
203
+
204
+ test("refuses stdin that is not base64", () => {
205
+ expect(() =>
206
+ decodeComputerHostRequestV1(
207
+ "exec",
208
+ request({ ...exec, stdinBase64: "not base64!" }),
209
+ ),
210
+ ).toThrow(/not valid base64/);
211
+ });
212
+
213
+ test("refuses a non-boolean stream flag", () => {
214
+ expect(() =>
215
+ decodeComputerHostRequestV1("exec", {
216
+ ...request(exec),
217
+ stream: "yes",
218
+ }),
219
+ ).toThrow(/stream must be a boolean/);
220
+ });
221
+ });
222
+
223
+ describe("paths", () => {
224
+ test("accepts an absolute normalized path", () => {
225
+ expect(decodeComputerPathV1("/home/box/agent-data/notes.md")).toBe(
226
+ "/home/box/agent-data/notes.md",
227
+ );
228
+ });
229
+
230
+ test.each([
231
+ ["relative", "home/box"],
232
+ ["traversal", "/home/box/../etc/shadow"],
233
+ ["dot segment", "/home/./box"],
234
+ ["double slash", "/home//box"],
235
+ ["backslash", "/home\\box"],
236
+ ["trailing slash", "/home/box/"],
237
+ ["control character", "/home/box/\u0007bell"],
238
+ ])("refuses a %s path", (_label, path) => {
239
+ expect(() => decodeComputerPathV1(path)).toThrow();
240
+ });
241
+ });
242
+
243
+ describe("file operations", () => {
244
+ test("round-trips a write with a mode", () => {
245
+ const operation: ComputerHostOperationV1 = {
246
+ kind: "file/write",
247
+ path: "/home/box/notes.md",
248
+ bytesBase64: btoa("hello"),
249
+ mode: 0o600,
250
+ };
251
+ expect(
252
+ decodeComputerHostRequestV1("file/write", request(operation)).operation,
253
+ ).toEqual(operation);
254
+ });
255
+
256
+ test("defaults recursive to false on a list", () => {
257
+ expect(
258
+ decodeComputerHostRequestV1(
259
+ "file/list",
260
+ request({ kind: "file/list", path: "/home/box", recursive: false }),
261
+ ).operation,
262
+ ).toEqual({ kind: "file/list", path: "/home/box", recursive: false });
263
+ });
264
+
265
+ test("refuses a mode outside the POSIX bits", () => {
266
+ expect(() =>
267
+ decodeComputerHostRequestV1("file/write", {
268
+ ...request({
269
+ kind: "file/write",
270
+ path: "/home/box/notes.md",
271
+ bytesBase64: "",
272
+ }),
273
+ mode: 0o10000,
274
+ }),
275
+ ).toThrow(/mode must be between/);
276
+ });
277
+ });
278
+
279
+ describe("control, viewer, service, cancel", () => {
280
+ test("round-trips a control acquisition", () => {
281
+ const operation: ComputerHostOperationV1 = {
282
+ kind: "control",
283
+ action: "acquire",
284
+ ownerId: "owner-1",
285
+ maxAgeSeconds: 90,
286
+ };
287
+ expect(
288
+ decodeComputerHostRequestV1("control", request(operation)).operation,
289
+ ).toEqual(operation);
290
+ });
291
+
292
+ test("refuses an unknown control action", () => {
293
+ expect(() =>
294
+ decodeComputerHostRequestV1("control", {
295
+ ...request({
296
+ kind: "control",
297
+ action: "acquire",
298
+ ownerId: "owner-1",
299
+ maxAgeSeconds: 90,
300
+ }),
301
+ action: "steal",
302
+ }),
303
+ ).toThrow(/control action is invalid/);
304
+ });
305
+
306
+ test("refuses a viewer revoke without a session", () => {
307
+ expect(() =>
308
+ decodeComputerHostRequestV1("viewer", {
309
+ ...envelope,
310
+ action: "revoke",
311
+ }),
312
+ ).toThrow(/revoke requires a session id/);
313
+ });
314
+
315
+ test("round-trips viewer renewal and requires its existing session", () => {
316
+ const operation: ComputerHostOperationV1 = {
317
+ kind: "viewer",
318
+ action: "renew",
319
+ sessionId: "viewer-1",
320
+ };
321
+ expect(
322
+ decodeComputerHostRequestV1("viewer", request(operation)).operation,
323
+ ).toEqual(operation);
324
+ expect(() =>
325
+ decodeComputerHostRequestV1("viewer", {
326
+ ...envelope,
327
+ action: "renew",
328
+ }),
329
+ ).toThrow(/renew requires a session id/);
330
+ });
331
+
332
+ test("a cancel names its effect through the envelope alone", () => {
333
+ const decoded = decodeComputerHostRequestV1(
334
+ "cancel",
335
+ request({ kind: "cancel" }),
336
+ );
337
+ expect(decoded.effectId).toBe("effect-1");
338
+ expect(decoded.operation).toEqual({ kind: "cancel" });
339
+ });
340
+
341
+ test("round-trips a service reattachment", () => {
342
+ expect(
343
+ decodeComputerHostRequestV1(
344
+ "service",
345
+ request({ kind: "service", name: "frockbot-workspace-sync" }),
346
+ ).operation,
347
+ ).toEqual({ kind: "service", name: "frockbot-workspace-sync" });
348
+ });
349
+ });
350
+
351
+ describe("HTTP decoding", () => {
352
+ function post(path: string, body: unknown, method = "POST"): Request {
353
+ return new Request(`http://computer-host.internal${path}`, {
354
+ method,
355
+ headers: { "content-type": "application/json" },
356
+ body: method === "GET" ? undefined : JSON.stringify(body),
357
+ });
358
+ }
359
+
360
+ test("decodes a well-formed request", async () => {
361
+ const decoded = await decodeComputerHostHttpRequestV1(
362
+ post(COMPUTER_HOST_ROUTES.open, request({ kind: "open" })),
363
+ );
364
+ expect(decoded.ok).toBe(true);
365
+ });
366
+
367
+ test("answers 404 for an unknown route", async () => {
368
+ const decoded = await decodeComputerHostHttpRequestV1(
369
+ post("/v1/computer/smoke", {}),
370
+ );
371
+ expect(decoded.ok).toBe(false);
372
+ if (decoded.ok) throw new Error("expected a refusal");
373
+ expect(decoded.response.status).toBe(404);
374
+ expect(
375
+ decodeComputerHostProblemV1(await decoded.response.json()).code,
376
+ ).toBe("not-found");
377
+ });
378
+
379
+ test("answers 405 for the wrong method", async () => {
380
+ const decoded = await decodeComputerHostHttpRequestV1(
381
+ post(COMPUTER_HOST_ROUTES.open, undefined, "GET"),
382
+ );
383
+ if (decoded.ok) throw new Error("expected a refusal");
384
+ expect(decoded.response.status).toBe(405);
385
+ });
386
+
387
+ test("answers 400 for a body that is not JSON", async () => {
388
+ const decoded = await decodeComputerHostHttpRequestV1(
389
+ new Request(`http://computer-host.internal${COMPUTER_HOST_ROUTES.open}`, {
390
+ method: "POST",
391
+ body: "{",
392
+ }),
393
+ );
394
+ if (decoded.ok) throw new Error("expected a refusal");
395
+ expect(decoded.response.status).toBe(400);
396
+ });
397
+
398
+ test("answers 413 for a request that exceeds a declared bound", async () => {
399
+ const decoded = await decodeComputerHostHttpRequestV1(
400
+ post(
401
+ COMPUTER_HOST_ROUTES.exec,
402
+ request({
403
+ kind: "exec",
404
+ script: "x".repeat(COMPUTER_HOST_LIMITS.script + 1),
405
+ timeoutMs: 1_000,
406
+ maxOutputBytes: 1_024,
407
+ stream: false,
408
+ }),
409
+ ),
410
+ );
411
+ if (decoded.ok) throw new Error("expected a refusal");
412
+ expect(decoded.response.status).toBe(413);
413
+ expect(
414
+ decodeComputerHostProblemV1(await decoded.response.json()).code,
415
+ ).toBe("limit-exceeded");
416
+ });
417
+ });
418
+
419
+ describe("results", () => {
420
+ test("round-trips an open result", () => {
421
+ const result = {
422
+ version: 1 as const,
423
+ effectId: "effect-1",
424
+ spriteName: "frockbot-0123456789ab",
425
+ directory: "agent-data/agents/bot-1",
426
+ display: ":100",
427
+ generation: 3,
428
+ };
429
+ expect(decodeComputerHostOpenResultV1(result)).toEqual(result);
430
+ });
431
+
432
+ test("distinguishes an in-place update from provisioning", () => {
433
+ const provisioning = {
434
+ kind: "update" as const,
435
+ phase: "runtime",
436
+ label: "Updating the Computer runtime",
437
+ index: 1,
438
+ total: 2,
439
+ status: "running" as const,
440
+ resumed: false,
441
+ };
442
+ expect(
443
+ decodeComputerHostOpenResultV1({
444
+ version: 1,
445
+ effectId: "effect-1",
446
+ spriteName: "frockbot-0123456789ab",
447
+ directory: "agent-data/agents/bot-1",
448
+ generation: 3,
449
+ provisioning,
450
+ }).provisioning,
451
+ ).toEqual(provisioning);
452
+ });
453
+
454
+ test("round-trips an exec result", () => {
455
+ const result = {
456
+ version: 1 as const,
457
+ effectId: "effect-1",
458
+ exitCode: 0,
459
+ stdoutBase64: btoa("out"),
460
+ stderrBase64: "",
461
+ outputTruncated: false,
462
+ };
463
+ expect(decodeComputerHostExecResultV1(result)).toEqual(result);
464
+ });
465
+
466
+ test("accepts a null exit code for a signalled command", () => {
467
+ expect(
468
+ decodeComputerHostExecResultV1({
469
+ version: 1,
470
+ effectId: "effect-1",
471
+ exitCode: null,
472
+ signal: "SIGTERM",
473
+ stdoutBase64: "",
474
+ stderrBase64: "",
475
+ outputTruncated: true,
476
+ }).signal,
477
+ ).toBe("SIGTERM");
478
+ });
479
+
480
+ test("round-trips the file results", () => {
481
+ const entry = {
482
+ path: "/home/box/notes.md",
483
+ kind: "file" as const,
484
+ size: 5,
485
+ mode: 0o644,
486
+ modifiedAt: "2026-08-31T00:00:00.000Z",
487
+ };
488
+ expect(
489
+ decodeComputerHostFileReadResultV1({
490
+ version: 1,
491
+ effectId: "effect-1",
492
+ entry,
493
+ bytesBase64: btoa("hello"),
494
+ }).entry,
495
+ ).toEqual(entry);
496
+ expect(
497
+ decodeComputerHostFileStatResultV1({
498
+ version: 1,
499
+ effectId: "effect-1",
500
+ entry,
501
+ }).entry,
502
+ ).toEqual(entry);
503
+ expect(
504
+ decodeComputerHostFileWriteResultV1({
505
+ version: 1,
506
+ effectId: "effect-1",
507
+ entry,
508
+ }).entry,
509
+ ).toEqual(entry);
510
+ expect(
511
+ decodeComputerHostFileListResultV1({
512
+ version: 1,
513
+ effectId: "effect-1",
514
+ entries: [entry],
515
+ truncated: false,
516
+ }).entries,
517
+ ).toEqual([entry]);
518
+ expect(
519
+ decodeComputerHostFileDeleteResultV1({
520
+ version: 1,
521
+ effectId: "effect-1",
522
+ path: entry.path,
523
+ deleted: true,
524
+ }).deleted,
525
+ ).toBe(true);
526
+ });
527
+
528
+ test("round-trips control, viewer, service, and cancel results", () => {
529
+ expect(
530
+ decodeComputerHostControlResultV1({
531
+ version: 1,
532
+ effectId: "effect-1",
533
+ action: "acquire",
534
+ ownerId: "owner-1",
535
+ expiresAt: "2026-08-31T00:01:30.000Z",
536
+ }).action,
537
+ ).toBe("acquire");
538
+ expect(
539
+ decodeComputerHostViewerResultV1({
540
+ version: 1,
541
+ effectId: "effect-1",
542
+ session: { id: "session-1", url: "https://example.invalid/vnc.html" },
543
+ }).session?.id,
544
+ ).toBe("session-1");
545
+ expect(
546
+ decodeComputerHostViewerResultV1({ version: 1, effectId: "effect-1" })
547
+ .session,
548
+ ).toBeUndefined();
549
+ expect(
550
+ decodeComputerHostServiceResultV1({
551
+ version: 1,
552
+ effectId: "effect-1",
553
+ name: "frockbot-viewer-gateway",
554
+ status: "running",
555
+ }).status,
556
+ ).toBe("running");
557
+ expect(
558
+ decodeComputerHostCancelResultV1({
559
+ version: 1,
560
+ effectId: "effect-1",
561
+ cancelled: false,
562
+ }).cancelled,
563
+ ).toBe(false);
564
+ });
565
+
566
+ test("refuses a result carrying an undeclared field", () => {
567
+ expect(() =>
568
+ decodeComputerHostCancelResultV1({
569
+ version: 1,
570
+ effectId: "effect-1",
571
+ cancelled: true,
572
+ spritesToken: "leaked",
573
+ }),
574
+ ).toThrow(/unknown field/);
575
+ });
576
+ });
577
+
578
+ describe("problems", () => {
579
+ test("computer-updating is retryable by default", () => {
580
+ expect(
581
+ computerHostProblemV1("computer-updating", "Updating runtime"),
582
+ ).toMatchObject({ code: "computer-updating", retryable: true });
583
+ });
584
+
585
+ test("problem() answers the declared shape", async () => {
586
+ const response = problem(
587
+ 503,
588
+ "provider-unavailable",
589
+ "container restarted",
590
+ );
591
+ expect(response.status).toBe(503);
592
+ const decoded = decodeComputerHostProblemV1(await response.json());
593
+ expect(decoded).toEqual({
594
+ version: 1,
595
+ code: "provider-unavailable",
596
+ message: "container restarted",
597
+ retryable: true,
598
+ });
599
+ });
600
+
601
+ test("a message longer than the bound is clipped rather than refused", () => {
602
+ expect(
603
+ computerHostProblemV1("provider-failure", "x".repeat(10_000)).message
604
+ .length,
605
+ ).toBe(COMPUTER_HOST_LIMITS.message);
606
+ });
607
+
608
+ test("refuses an unknown problem code", () => {
609
+ expect(() =>
610
+ decodeComputerHostProblemV1({
611
+ version: 1,
612
+ code: "kaboom",
613
+ message: "no",
614
+ retryable: false,
615
+ }),
616
+ ).toThrow(/code is invalid/);
617
+ });
618
+ });
619
+
620
+ describe("exec frames", () => {
621
+ test("round-trips every frame type", () => {
622
+ const frames = [
623
+ { type: "stdout" as const, dataBase64: btoa("out") },
624
+ { type: "stderr" as const, dataBase64: btoa("err") },
625
+ { type: "exit" as const, exitCode: 0, outputTruncated: false },
626
+ {
627
+ type: "exit" as const,
628
+ exitCode: null,
629
+ signal: "SIGTERM",
630
+ outputTruncated: true,
631
+ },
632
+ {
633
+ type: "error" as const,
634
+ code: "limit-exceeded" as const,
635
+ message: "too many",
636
+ retryable: true,
637
+ },
638
+ ];
639
+ for (const frame of frames) {
640
+ expect(
641
+ decodeComputerHostExecFrameV1(
642
+ encodeComputerHostExecFrameV1(frame).trimEnd(),
643
+ ),
644
+ ).toEqual(frame);
645
+ }
646
+ });
647
+
648
+ test("refuses an unknown frame type", () => {
649
+ expect(() => decodeComputerHostExecFrameV1('{"type":"log"}')).toThrow(
650
+ /frame type is invalid/,
651
+ );
652
+ });
653
+
654
+ test("reassembles frames across arbitrary chunk boundaries", () => {
655
+ const frames = [
656
+ { type: "stdout" as const, dataBase64: btoa("one") },
657
+ { type: "stderr" as const, dataBase64: btoa("two") },
658
+ { type: "exit" as const, exitCode: 0, outputTruncated: false },
659
+ ];
660
+ const wire = frames.map(encodeComputerHostExecFrameV1).join("");
661
+ for (const size of [1, 2, 3, 7, 13, 64, wire.length]) {
662
+ const reader = new ComputerHostExecFrameReaderV1();
663
+ const seen = [];
664
+ for (let index = 0; index < wire.length; index += size) {
665
+ seen.push(...reader.push(wire.slice(index, index + size)));
666
+ }
667
+ seen.push(...reader.end());
668
+ expect(seen).toEqual(frames);
669
+ }
670
+ });
671
+
672
+ test("reassembles a stream whose last frame has no trailing newline", () => {
673
+ const reader = new ComputerHostExecFrameReaderV1();
674
+ expect(
675
+ reader.push('{"type":"exit","exitCode":0,"outputTruncated":false}'),
676
+ ).toEqual([]);
677
+ expect(reader.end()).toEqual([
678
+ { type: "exit", exitCode: 0, outputTruncated: false },
679
+ ]);
680
+ });
681
+
682
+ test("reassembles when a whole frame arrives inside one coalesced chunk", () => {
683
+ const reader = new ComputerHostExecFrameReaderV1();
684
+ const wire = [
685
+ { type: "stdout" as const, dataBase64: btoa("a") },
686
+ { type: "stdout" as const, dataBase64: btoa("b") },
687
+ ]
688
+ .map(encodeComputerHostExecFrameV1)
689
+ .join("");
690
+ expect(reader.push(new TextEncoder().encode(wire))).toHaveLength(2);
691
+ });
692
+ });
693
+
694
+ describe("base64 fields", () => {
695
+ test("accepts an empty payload", () => {
696
+ expect(decodeBase64FieldV1("", "payload")).toBe("");
697
+ });
698
+
699
+ test("refuses a payload beyond its bound", () => {
700
+ expect(() => decodeBase64FieldV1("AAAA", "payload", 2)).toThrow(
701
+ /exceeds 2 encoded bytes/,
702
+ );
703
+ });
704
+ });