@robota-sdk/agent-transport 3.0.0-beta.100

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.
Files changed (93) hide show
  1. package/CHANGELOG.md +1137 -0
  2. package/LICENSE +661 -0
  3. package/README.md +121 -0
  4. package/dist/browser/client.d.ts +2 -0
  5. package/dist/browser/client.js +1 -0
  6. package/dist/browser/index.d.ts +624 -0
  7. package/dist/browser/index.d.ts.map +1 -0
  8. package/dist/browser/index.js +2 -0
  9. package/dist/browser/index.js.map +1 -0
  10. package/dist/browser/observer-messages-4fRQZU2D.d.ts +499 -0
  11. package/dist/browser/observer-messages-4fRQZU2D.d.ts.map +1 -0
  12. package/dist/browser/observer-messages-O8jTPEQO.js +2 -0
  13. package/dist/browser/observer-messages-O8jTPEQO.js.map +1 -0
  14. package/dist/node/index.cjs +1 -0
  15. package/dist/node/index.d.cts +1115 -0
  16. package/dist/node/index.d.cts.map +1 -0
  17. package/dist/node/index.d.ts +1115 -0
  18. package/dist/node/index.d.ts.map +1 -0
  19. package/dist/node/index.js +2 -0
  20. package/dist/node/index.js.map +1 -0
  21. package/dist/node/node.cjs +1 -0
  22. package/dist/node/node.d.cts +317 -0
  23. package/dist/node/node.d.cts.map +1 -0
  24. package/dist/node/node.d.ts +317 -0
  25. package/dist/node/node.d.ts.map +1 -0
  26. package/dist/node/node.js +2 -0
  27. package/dist/node/node.js.map +1 -0
  28. package/package.json +122 -0
  29. package/src/__tests__/__fixtures__/korean-text.json +6 -0
  30. package/src/__tests__/access-token-verifier.test.ts +672 -0
  31. package/src/__tests__/admission.test.ts +130 -0
  32. package/src/__tests__/app-tool-messages.test.ts +463 -0
  33. package/src/__tests__/bearer-resource-gate.test.ts +120 -0
  34. package/src/__tests__/channel-frames.test.ts +215 -0
  35. package/src/__tests__/driver-attribution-capability.test.ts +83 -0
  36. package/src/__tests__/file-transfer.test.ts +281 -0
  37. package/src/__tests__/handoff-chunking.test.ts +261 -0
  38. package/src/__tests__/handoff-manifest.test.ts +52 -0
  39. package/src/__tests__/history-attached-budget.test.ts +216 -0
  40. package/src/__tests__/message-decoders.test.ts +870 -0
  41. package/src/__tests__/outbound-delivery.test.ts +558 -0
  42. package/src/__tests__/peer-message-ledger.test.ts +200 -0
  43. package/src/__tests__/permission-summary.test.ts +18 -0
  44. package/src/__tests__/personal-usage-report.test.ts +193 -0
  45. package/src/__tests__/project-read-messages.test.ts +118 -0
  46. package/src/__tests__/resume-buffer.test.ts +93 -0
  47. package/src/__tests__/session-detail-loop-status.test.ts +169 -0
  48. package/src/__tests__/session-directory-messages.test.ts +322 -0
  49. package/src/__tests__/session-error-classification.test.ts +113 -0
  50. package/src/__tests__/session-event-delivery.test.ts +101 -0
  51. package/src/__tests__/session-history-wire.test.ts +364 -0
  52. package/src/__tests__/session-message-handler-e5.test.ts +99 -0
  53. package/src/__tests__/session-message-handler-observe.test.ts +223 -0
  54. package/src/__tests__/session-message-handler.test.ts +726 -0
  55. package/src/__tests__/session-resume-bridge.test.ts +472 -0
  56. package/src/__tests__/settings-messages.test.ts +193 -0
  57. package/src/__tests__/stdio-carrier.test.ts +85 -0
  58. package/src/__tests__/submit-prompt-shape.test.ts +78 -0
  59. package/src/__tests__/usage-report-carrier.test.ts +98 -0
  60. package/src/__tests__/ws-broadcast-events.test.ts +94 -0
  61. package/src/app-tool-messages.ts +140 -0
  62. package/src/background-messages.ts +169 -0
  63. package/src/channel-frames.ts +182 -0
  64. package/src/client.ts +17 -0
  65. package/src/handoff-chunking.ts +275 -0
  66. package/src/index.ts +135 -0
  67. package/src/loop-control-messages.ts +34 -0
  68. package/src/message-decoders.ts +485 -0
  69. package/src/message-parser.ts +33 -0
  70. package/src/node/access-token-verifier.ts +363 -0
  71. package/src/node/admission.ts +153 -0
  72. package/src/node/bearer-resource-gate.ts +331 -0
  73. package/src/node/file-transfer.ts +530 -0
  74. package/src/node/handoff-manifest.ts +77 -0
  75. package/src/node/index.ts +45 -0
  76. package/src/node/stdio-carrier.ts +59 -0
  77. package/src/observer-messages.ts +47 -0
  78. package/src/open-prompts.ts +76 -0
  79. package/src/outbound-delivery.ts +240 -0
  80. package/src/peer-message-ledger.ts +193 -0
  81. package/src/permission-summary.ts +18 -0
  82. package/src/project-read-messages.ts +74 -0
  83. package/src/protocol-session.ts +56 -0
  84. package/src/resume-buffer.ts +123 -0
  85. package/src/session-directory-messages.ts +162 -0
  86. package/src/session-error-classification.ts +91 -0
  87. package/src/session-events.ts +290 -0
  88. package/src/session-message-handler.ts +413 -0
  89. package/src/session-query-messages.ts +160 -0
  90. package/src/session-resume-bridge.ts +275 -0
  91. package/src/settings-messages.ts +103 -0
  92. package/src/usage-messages.ts +195 -0
  93. package/src/wire-messages.ts +436 -0
@@ -0,0 +1,281 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import {
6
+ FILE_CREDIT_WINDOW,
7
+ receiveFileOverChannel,
8
+ sendFileOverChannel,
9
+ type IFileSink,
10
+ type IFileSource,
11
+ type TFileAdmission,
12
+ } from '../node/file-transfer.js';
13
+
14
+ import type { IFileFrameChannel, IFileOffer } from '@robota-sdk/agent-interface-session-mobility';
15
+
16
+ /** Two ends of an in-memory channel. Frames arrive asynchronously, in order; `sent` records one side. */
17
+ function channelPair(): { a: IFileFrameChannel & { sent: string[] }; b: IFileFrameChannel } {
18
+ type TEnd = IFileFrameChannel & {
19
+ sent: string[];
20
+ frames: Set<(frame: string) => void>;
21
+ closes: Set<() => void>;
22
+ closed: boolean;
23
+ other?: TEnd;
24
+ };
25
+ const make = (): TEnd => {
26
+ const end: TEnd = {
27
+ sent: [],
28
+ frames: new Set(),
29
+ closes: new Set(),
30
+ closed: false,
31
+ send(frame) {
32
+ if (end.closed) throw new Error('closed');
33
+ end.sent.push(frame);
34
+ const other = end.other!;
35
+ setImmediate(() => {
36
+ if (!other.closed) for (const handler of other.frames) handler(frame);
37
+ });
38
+ },
39
+ onFrame(handler) {
40
+ end.frames.add(handler);
41
+ return () => end.frames.delete(handler);
42
+ },
43
+ onClose(handler) {
44
+ end.closes.add(handler);
45
+ return () => end.closes.delete(handler);
46
+ },
47
+ close() {
48
+ for (const side of [end, end.other!]) {
49
+ if (side.closed) continue;
50
+ side.closed = true;
51
+ setImmediate(() => {
52
+ for (const handler of side.closes) handler();
53
+ });
54
+ }
55
+ },
56
+ };
57
+ return end;
58
+ };
59
+ const a = make();
60
+ const b = make();
61
+ a.other = b;
62
+ b.other = a;
63
+ return { a, b };
64
+ }
65
+
66
+ function sha256(bytes: Uint8Array): string {
67
+ return createHash('sha256').update(bytes).digest('hex');
68
+ }
69
+
70
+ function sourceOf(bytes: Buffer, pieces = 7_000): IFileSource {
71
+ return {
72
+ size: bytes.length,
73
+ async *read() {
74
+ for (let at = 0; at < bytes.length; at += pieces) yield bytes.subarray(at, at + pieces);
75
+ },
76
+ };
77
+ }
78
+
79
+ function memorySink(): IFileSink & { written: Buffer[]; state: 'open' | 'kept' | 'discarded' } {
80
+ const sink = {
81
+ written: [] as Buffer[],
82
+ state: 'open' as 'open' | 'kept' | 'discarded',
83
+ write: async (chunk: Uint8Array) => {
84
+ sink.written.push(Buffer.from(chunk));
85
+ },
86
+ commit: async () => {
87
+ sink.state = 'kept';
88
+ return '/quarantine/file';
89
+ },
90
+ discard: async () => {
91
+ sink.state = 'discarded';
92
+ },
93
+ };
94
+ return sink;
95
+ }
96
+
97
+ function offerFor(bytes: Buffer, over: Partial<IFileOffer> = {}): IFileOffer {
98
+ return { transferId: 't1', name: 'data.bin', size: bytes.length, sha256: sha256(bytes), ...over };
99
+ }
100
+
101
+ describe('file carrier', () => {
102
+ it('moves a file the receiver admits, verified whole', async () => {
103
+ const bytes = Buffer.from(Array.from({ length: 100_000 }, (_, i) => i % 251));
104
+ const { a, b } = channelPair();
105
+ const sink = memorySink();
106
+ const offers: IFileOffer[] = [];
107
+ const [sent, received] = await Promise.all([
108
+ sendFileOverChannel({ channel: a, offer: offerFor(bytes), source: sourceOf(bytes) }),
109
+ receiveFileOverChannel({
110
+ channel: b,
111
+ admit: async (offer) => {
112
+ offers.push(offer);
113
+ return { sink };
114
+ },
115
+ }),
116
+ ]);
117
+ expect(sent).toEqual({ ok: true });
118
+ expect(received).toMatchObject({ ok: true, location: '/quarantine/file' });
119
+ expect(Buffer.concat(sink.written).equals(bytes)).toBe(true);
120
+ expect(sink.state).toBe('kept');
121
+ expect(offers).toEqual([offerFor(bytes)]);
122
+ });
123
+
124
+ it('sends no content before the receiver answers, and nothing at all when it refuses', async () => {
125
+ const bytes = Buffer.from('secret-ish');
126
+ const { a, b } = channelPair();
127
+ const [sent, received] = await Promise.all([
128
+ sendFileOverChannel({ channel: a, offer: offerFor(bytes), source: sourceOf(bytes) }),
129
+ receiveFileOverChannel({
130
+ channel: b,
131
+ admit: async (): Promise<TFileAdmission> => ({ refused: 'declined' }),
132
+ }),
133
+ ]);
134
+ expect(sent).toMatchObject({ ok: false, reason: 'declined' });
135
+ expect(received).toMatchObject({ ok: false, reason: 'declined' });
136
+ expect(a.sent.map((frame) => (JSON.parse(frame) as { t: string }).t)).toEqual(['file-offer']);
137
+ });
138
+
139
+ it('refuses an offer over the size limit without asking anyone', async () => {
140
+ const bytes = Buffer.alloc(2_000);
141
+ const { a, b } = channelPair();
142
+ let asked = false;
143
+ const [sent, received] = await Promise.all([
144
+ sendFileOverChannel({ channel: a, offer: offerFor(bytes), source: sourceOf(bytes) }),
145
+ receiveFileOverChannel({
146
+ channel: b,
147
+ maxBytes: 1_000,
148
+ admit: async () => {
149
+ asked = true;
150
+ return { sink: memorySink() };
151
+ },
152
+ }),
153
+ ]);
154
+ expect(asked).toBe(false);
155
+ expect(sent).toMatchObject({ ok: false, reason: 'too-large' });
156
+ expect(received).toMatchObject({ ok: false, reason: 'too-large' });
157
+ });
158
+
159
+ it('discards content that does not match the offered hash', async () => {
160
+ const bytes = Buffer.from('the real content');
161
+ const { a, b } = channelPair();
162
+ const sink = memorySink();
163
+ const [sent, received] = await Promise.all([
164
+ sendFileOverChannel({
165
+ channel: a,
166
+ offer: offerFor(bytes, { sha256: sha256(Buffer.from('something else')) }),
167
+ source: sourceOf(bytes),
168
+ }),
169
+ receiveFileOverChannel({ channel: b, admit: async () => ({ sink }) }),
170
+ ]);
171
+ expect(received).toMatchObject({ ok: false, reason: 'integrity' });
172
+ expect(sent).toMatchObject({ ok: false, reason: 'integrity' });
173
+ expect(sink.state).toBe('discarded');
174
+ });
175
+
176
+ it('discards a transfer the sender abandons midway', async () => {
177
+ const bytes = Buffer.alloc(200_000, 1);
178
+ const { a, b } = channelPair();
179
+ const sink = memorySink();
180
+ const source: IFileSource = {
181
+ size: bytes.length,
182
+ async *read() {
183
+ yield bytes.subarray(0, 50_000);
184
+ throw new Error('disk went away');
185
+ },
186
+ };
187
+ const [sent, received] = await Promise.all([
188
+ sendFileOverChannel({ channel: a, offer: offerFor(bytes), source }),
189
+ receiveFileOverChannel({ channel: b, admit: async () => ({ sink }) }),
190
+ ]);
191
+ expect(sent.ok).toBe(false);
192
+ expect(received).toMatchObject({ ok: false, reason: 'closed' });
193
+ expect(sink.state).toBe('discarded');
194
+ });
195
+
196
+ it('keeps no more than the credit window in flight', async () => {
197
+ const bytes = Buffer.alloc(40 * 1024 * 16, 7);
198
+ const { a, b } = channelPair();
199
+ let release: () => void = () => undefined;
200
+ const stalled = new Promise<void>((resolve) => {
201
+ release = resolve;
202
+ });
203
+ let writes = 0;
204
+ const sink: IFileSink = {
205
+ write: async () => {
206
+ writes += 1;
207
+ // The receiver's disk stalls on the first write.
208
+ if (writes === 1) await stalled;
209
+ },
210
+ commit: async () => '/q',
211
+ discard: async () => undefined,
212
+ };
213
+ const sending = sendFileOverChannel({
214
+ channel: a,
215
+ offer: offerFor(bytes),
216
+ source: sourceOf(bytes),
217
+ });
218
+ const receiving = receiveFileOverChannel({ channel: b, admit: async () => ({ sink }) });
219
+ await new Promise((resolve) => setTimeout(resolve, 100));
220
+ const chunks = a.sent.filter((frame) => frame.includes('"file-chunk"')).length;
221
+ expect(chunks).toBe(FILE_CREDIT_WINDOW);
222
+ release();
223
+ await expect(sending).resolves.toEqual({ ok: true });
224
+ await expect(receiving).resolves.toMatchObject({ ok: true });
225
+ });
226
+
227
+ it('ends a transfer whose sender ignores its credit', async () => {
228
+ const { a, b } = channelPair();
229
+ const bytes = Buffer.alloc(10);
230
+ const receiving = receiveFileOverChannel({
231
+ channel: b,
232
+ admit: async () => ({
233
+ sink: {
234
+ write: () => new Promise<void>((resolve) => setTimeout(resolve, 50)),
235
+ commit: async () => '',
236
+ discard: async () => undefined,
237
+ },
238
+ }),
239
+ });
240
+ a.onFrame(() => undefined);
241
+ a.send(JSON.stringify({ t: 'file-offer', ...offerFor(bytes, { size: 10 * 1024 * 1024 }) }));
242
+ await new Promise((resolve) => setTimeout(resolve, 20));
243
+ for (let seq = 0; seq < FILE_CREDIT_WINDOW + 5; seq += 1) {
244
+ a.send(JSON.stringify({ t: 'file-chunk', seq, data: Buffer.alloc(16).toString('base64') }));
245
+ }
246
+ await expect(receiving).resolves.toMatchObject({ ok: false, reason: 'protocol' });
247
+ });
248
+
249
+ it('passes on a refusal detail only as printable text', async () => {
250
+ const bytes = Buffer.from('x');
251
+ const { a, b } = channelPair();
252
+ b.onFrame(() => {
253
+ b.send(
254
+ JSON.stringify({ t: 'file-refuse', reason: 'declined', detail: 'no\u001b[2J‮thanks' }),
255
+ );
256
+ });
257
+ const sent = await sendFileOverChannel({
258
+ channel: a,
259
+ offer: offerFor(bytes),
260
+ source: sourceOf(bytes),
261
+ });
262
+ expect(sent).toMatchObject({ ok: false, reason: 'declined' });
263
+ const detail = sent.ok ? '' : (sent.detail ?? '');
264
+ expect(detail).toContain('thanks');
265
+ expect([...detail].some((char) => (char.codePointAt(0) ?? 0) < 0x20)).toBe(false);
266
+ expect(detail).not.toContain('‮');
267
+ });
268
+
269
+ it('ends a transfer that sends an empty chunk', async () => {
270
+ const { a, b } = channelPair();
271
+ const bytes = Buffer.alloc(10);
272
+ const sink = memorySink();
273
+ const receiving = receiveFileOverChannel({ channel: b, admit: async () => ({ sink }) });
274
+ a.onFrame(() => undefined);
275
+ a.send(JSON.stringify({ t: 'file-offer', ...offerFor(bytes) }));
276
+ await new Promise((resolve) => setTimeout(resolve, 20));
277
+ a.send(JSON.stringify({ t: 'file-chunk', seq: 0, data: '' }));
278
+ await expect(receiving).resolves.toMatchObject({ ok: false, reason: 'protocol' });
279
+ expect(sink.state).toBe('discarded');
280
+ });
281
+ });
@@ -0,0 +1,261 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import {
4
+ chunkCountFor,
5
+ chunkHandoffPayload,
6
+ HandoffChunkAssembler,
7
+ type IHandoffChunk,
8
+ } from '../handoff-chunking.js';
9
+ import { sealHandoffRecord, verifyHandoffPayload } from '../node/handoff-manifest.js';
10
+ import koreanText from './__fixtures__/korean-text.json';
11
+
12
+ import type { IInteractiveSessionRecord } from '@robota-sdk/agent-interface-session';
13
+
14
+ const ID = 'handoff_1';
15
+
16
+ function assemble(chunks: readonly IHandoffChunk[], assembler = new HandoffChunkAssembler(ID)) {
17
+ let last;
18
+ for (const chunk of chunks) last = assembler.accept(chunk);
19
+ return { assembler, last };
20
+ }
21
+
22
+ describe('HANDOFF-001 — a payload larger than one frame travels in pieces', () => {
23
+ it('every chunk fits the byte budget', () => {
24
+ // The channel's limit is in bytes, so the budget has to be honoured in bytes. A payload of
25
+ // multi-byte characters is where a character-based cut overflows the frame.
26
+ const payload = koreanText.hangulWord.repeat(500);
27
+
28
+ const chunks = chunkHandoffPayload(ID, payload, 64);
29
+
30
+ for (const chunk of chunks) {
31
+ expect(Buffer.from(chunk.data, 'base64').byteLength).toBeLessThanOrEqual(64);
32
+ }
33
+ });
34
+
35
+ it('round-trips a payload whose characters do not align to the cut', () => {
36
+ // The cut lands mid-character by construction here. Reassembly is on bytes, so the boundary
37
+ // is invisible to the result — which is the property the byte-based design buys.
38
+ const payload = `${'a'.repeat(30)}😀${'b'.repeat(30)}`;
39
+
40
+ const { last } = assemble(chunkHandoffPayload(ID, payload, 16));
41
+
42
+ expect(last?.outcome).toBe('complete');
43
+ expect(last?.serialized).toBe(payload);
44
+ });
45
+
46
+ it('an empty payload is one chunk, not zero', () => {
47
+ // Zero chunks would leave a receiver unable to tell a completed empty transfer from one that
48
+ // never started.
49
+ const chunks = chunkHandoffPayload(ID, '', 64);
50
+
51
+ expect(chunks).toHaveLength(1);
52
+ expect(assemble(chunks).last?.serialized).toBe('');
53
+ });
54
+
55
+ it('every chunk carries the total, so no single lost frame hides the count', () => {
56
+ const chunks = chunkHandoffPayload(ID, 'x'.repeat(100), 10);
57
+
58
+ expect(chunks.every((c) => c.total === chunks.length)).toBe(true);
59
+ });
60
+
61
+ it('refuses a budget that would produce an endless sequence of empty chunks', () => {
62
+ expect(() => chunkHandoffPayload(ID, 'x', 0)).toThrow(/positive integer/);
63
+ });
64
+
65
+ it('predicts the chunk count from the manifest without building them', () => {
66
+ const { serialized, integrity } = sealHandoffRecord({
67
+ id: 's',
68
+ cwd: '/w',
69
+ createdAt: 'a',
70
+ updatedAt: 'b',
71
+ messages: [],
72
+ } as IInteractiveSessionRecord);
73
+
74
+ expect(chunkCountFor(integrity, 16)).toBe(chunkHandoffPayload(ID, serialized, 16).length);
75
+ });
76
+ });
77
+
78
+ describe('HANDOFF-001 — reordering and retries are normal; inconsistency is not', () => {
79
+ it('accepts chunks out of order', () => {
80
+ const chunks = [...chunkHandoffPayload(ID, 'x'.repeat(100), 10)].reverse();
81
+
82
+ const { last } = assemble(chunks);
83
+
84
+ expect(last?.outcome).toBe('complete');
85
+ expect(last?.serialized).toBe('x'.repeat(100));
86
+ });
87
+
88
+ it('a re-sent chunk is a duplicate, not a problem', () => {
89
+ const chunks = chunkHandoffPayload(ID, 'x'.repeat(100), 10);
90
+ const assembler = new HandoffChunkAssembler(ID);
91
+ assembler.accept(chunks[0]);
92
+
93
+ const again = assembler.accept(chunks[0]);
94
+
95
+ expect(again.outcome).toBe('duplicate');
96
+ expect(again.received).toBe(1);
97
+ });
98
+
99
+ it('different bytes for the same index is refused, not overwritten', () => {
100
+ const chunks = chunkHandoffPayload(ID, 'x'.repeat(100), 10);
101
+ const assembler = new HandoffChunkAssembler(ID);
102
+ assembler.accept(chunks[0]);
103
+
104
+ const conflicting = assembler.accept({
105
+ ...chunks[0],
106
+ data: Buffer.from('zz').toString('base64'),
107
+ });
108
+
109
+ expect(conflicting.outcome).toBe('refused');
110
+ });
111
+
112
+ it('a peer that changes its mind about the size is refused', () => {
113
+ // Either it restarted the transfer under the same id, or it is not the peer we started with.
114
+ // Both mean the pieces held so far can no longer be assumed to belong together.
115
+ const chunks = chunkHandoffPayload(ID, 'x'.repeat(100), 10);
116
+ const assembler = new HandoffChunkAssembler(ID);
117
+ assembler.accept(chunks[0]);
118
+
119
+ const result = assembler.accept({ ...chunks[1], total: 3 });
120
+
121
+ expect(result.rejection).toBe('inconsistent-total');
122
+ });
123
+
124
+ it('refuses a chunk belonging to a different transfer rather than routing it', () => {
125
+ const [chunk] = chunkHandoffPayload('other_handoff', 'x', 10);
126
+
127
+ expect(new HandoffChunkAssembler(ID).accept(chunk).rejection).toBe('wrong-handoff');
128
+ });
129
+
130
+ it.each([
131
+ ['a negative index', -1],
132
+ ['an index beyond the total', 99],
133
+ ])('refuses %s', (_label, index) => {
134
+ const [chunk] = chunkHandoffPayload(ID, 'x'.repeat(30), 10);
135
+
136
+ expect(new HandoffChunkAssembler(ID).accept({ ...chunk, index }).rejection).toBe(
137
+ 'out-of-range',
138
+ );
139
+ });
140
+
141
+ it('reports which indices are still missing, so a caller can ask rather than restart', () => {
142
+ const chunks = chunkHandoffPayload(ID, 'x'.repeat(100), 10);
143
+ const assembler = new HandoffChunkAssembler(ID);
144
+ assembler.accept(chunks[0]);
145
+ assembler.accept(chunks[3]);
146
+
147
+ expect(assembler.missing()).toEqual([1, 2, 4, 5, 6, 7, 8, 9]);
148
+ });
149
+
150
+ it('knows nothing is missing before anything has declared a total', () => {
151
+ expect(new HandoffChunkAssembler(ID).missing()).toEqual([]);
152
+ });
153
+
154
+ it('discard drops what it held', () => {
155
+ const chunks = chunkHandoffPayload(ID, 'x'.repeat(100), 10);
156
+ const assembler = new HandoffChunkAssembler(ID);
157
+ assembler.accept(chunks[0]);
158
+
159
+ assembler.discard();
160
+
161
+ expect(assembler.received).toBe(0);
162
+ expect(assembler.expected).toBeUndefined();
163
+ });
164
+ });
165
+
166
+ describe('HANDOFF-001 TC-06 — a mangled chunk is caught where it arrives', () => {
167
+ it('refuses data that is not decodable base64', () => {
168
+ // Buffer.from is lenient: it drops what it cannot parse rather than throwing, so a mangled
169
+ // chunk would silently become a shorter one and corrupt the payload in a way only the final
170
+ // digest catches — after the whole transfer completed.
171
+ const [chunk] = chunkHandoffPayload(ID, 'x'.repeat(30), 10);
172
+
173
+ const result = new HandoffChunkAssembler(ID).accept({ ...chunk, data: 'not!valid!base64!' });
174
+
175
+ expect(result.rejection).toBe('undecodable');
176
+ });
177
+
178
+ it('a corrupt-but-decodable chunk survives here and is caught by the digest', () => {
179
+ // The two checks are deliberately different questions. This one asks "did the pieces arrive";
180
+ // the digest asks "are they the pieces that were sent". Asking the second one per chunk would
181
+ // report each piece intact while the set is not the set that was sent.
182
+ const record = {
183
+ id: 's',
184
+ cwd: '/w',
185
+ createdAt: 'a',
186
+ updatedAt: 'b',
187
+ messages: [],
188
+ } as IInteractiveSessionRecord;
189
+ const { serialized, integrity } = sealHandoffRecord(record);
190
+ const chunks = chunkHandoffPayload(ID, serialized, 16);
191
+ const swapped = chunks.map((c, i) =>
192
+ i === 0 ? { ...c, data: Buffer.from('0'.repeat(16)).toString('base64') } : c,
193
+ );
194
+
195
+ const { last } = assemble(swapped);
196
+
197
+ expect(last?.outcome).toBe('complete');
198
+ expect(verifyHandoffPayload(last?.serialized ?? '', integrity).intact).toBe(false);
199
+ });
200
+
201
+ it('the whole path holds: seal, chunk, reassemble, verify', () => {
202
+ const record = {
203
+ id: 'session_1',
204
+ cwd: '/home/alice/project',
205
+ createdAt: '2026-08-17T00:00:00.000Z',
206
+ updatedAt: '2026-08-17T01:00:00.000Z',
207
+ messages: [{ role: 'user', content: koreanText.greetingWithSmileEmoji }],
208
+ } as unknown as IInteractiveSessionRecord;
209
+ const { serialized, integrity } = sealHandoffRecord(record);
210
+
211
+ const { last } = assemble(chunkHandoffPayload(ID, serialized, 24));
212
+
213
+ expect(verifyHandoffPayload(last?.serialized ?? '', integrity).intact).toBe(true);
214
+ expect(JSON.parse(last?.serialized ?? '{}')).toEqual(record);
215
+ });
216
+ });
217
+
218
+ describe('without Node’s Buffer, as in the browser build', () => {
219
+ afterEach(() => {
220
+ vi.unstubAllGlobals();
221
+ });
222
+
223
+ it('chunks and reassembles a multi-byte payload', () => {
224
+ vi.stubGlobal('Buffer', undefined);
225
+ const serialized = JSON.stringify({
226
+ text: koreanText.handoffLabelWithCheckmarkAndTrailingSpace.repeat(200),
227
+ });
228
+ const assembler = new HandoffChunkAssembler('h-browser');
229
+ let result: ReturnType<HandoffChunkAssembler['accept']> | undefined;
230
+
231
+ for (const chunk of chunkHandoffPayload('h-browser', serialized, 64)) {
232
+ result = assembler.accept(chunk);
233
+ }
234
+
235
+ expect(result).toMatchObject({ outcome: 'complete', serialized });
236
+ });
237
+
238
+ it('keeps a leading byte-order mark', () => {
239
+ vi.stubGlobal('Buffer', undefined);
240
+ const serialized = '\uFEFF{"text":"bom"}';
241
+ const assembler = new HandoffChunkAssembler('h-bom');
242
+ let result: ReturnType<HandoffChunkAssembler['accept']> | undefined;
243
+
244
+ for (const chunk of chunkHandoffPayload('h-bom', serialized, 4)) {
245
+ result = assembler.accept(chunk);
246
+ }
247
+
248
+ expect(result).toMatchObject({ outcome: 'complete', serialized });
249
+ });
250
+
251
+ it('refuses a chunk whose base64 is not canonical', () => {
252
+ vi.stubGlobal('Buffer', undefined);
253
+ const [chunk] = chunkHandoffPayload('h-browser', 'hello', 64);
254
+ const assembler = new HandoffChunkAssembler('h-browser');
255
+
256
+ expect(assembler.accept({ ...chunk!, data: ` ${chunk!.data}` })).toMatchObject({
257
+ outcome: 'refused',
258
+ rejection: 'undecodable',
259
+ });
260
+ });
261
+ });
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { sealHandoffRecord, verifyHandoffPayload } from '../node/handoff-manifest.js';
4
+
5
+ import type { IInteractiveSessionRecord } from '@robota-sdk/agent-interface-session';
6
+
7
+ function aRecord(over: Partial<IInteractiveSessionRecord> = {}): IInteractiveSessionRecord {
8
+ return {
9
+ id: 'session_1',
10
+ cwd: '/home/alice/project',
11
+ createdAt: '2026-08-17T00:00:00.000Z',
12
+ updatedAt: '2026-08-17T01:00:00.000Z',
13
+ messages: [],
14
+ ...over,
15
+ };
16
+ }
17
+
18
+ describe('HANDOFF-001 TC-06 — a payload that did not arrive whole is refused', () => {
19
+ it('accepts the exact bytes that were sealed', () => {
20
+ const { serialized, integrity } = sealHandoffRecord(aRecord());
21
+
22
+ expect(verifyHandoffPayload(serialized, integrity).intact).toBe(true);
23
+ });
24
+
25
+ it('reports a truncated payload as truncated, not as corruption', () => {
26
+ // Checked BEFORE the digest. Hashing a short buffer to discover it was short wastes the work
27
+ // and reports a dropped connection as tampering.
28
+ const { serialized, integrity } = sealHandoffRecord(aRecord());
29
+
30
+ const verdict = verifyHandoffPayload(serialized.slice(0, -20), integrity);
31
+
32
+ expect(verdict.failure).toBe('truncated');
33
+ expect(verdict.expectedBytes).toBe(integrity.byteLength);
34
+ expect(verdict.actualBytes).toBeLessThan(integrity.byteLength);
35
+ });
36
+
37
+ it('reports a same-length substitution as a digest mismatch', () => {
38
+ // The case length alone cannot catch, which is why the digest is there at all.
39
+ const { serialized, integrity } = sealHandoffRecord(aRecord());
40
+ const tampered = serialized.replace('/home/alice/project', '/home/mallor/projec');
41
+
42
+ expect(tampered).toHaveLength(serialized.length);
43
+ expect(verifyHandoffPayload(tampered, integrity).failure).toBe('digest-mismatch');
44
+ });
45
+
46
+ it('two different records do not seal to the same digest', () => {
47
+ const a = sealHandoffRecord(aRecord());
48
+ const b = sealHandoffRecord(aRecord({ cwd: '/elsewhere' }));
49
+
50
+ expect(a.integrity.digest).not.toBe(b.integrity.digest);
51
+ });
52
+ });