@robota-sdk/agent-transport 3.0.0-beta.81 → 3.0.0-beta.82

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robota-sdk/agent-transport",
3
- "version": "3.0.0-beta.81",
3
+ "version": "3.0.0-beta.82",
4
4
  "description": "Transport package boundary for Robota SDK — runtime hosts are owned by agent-framework",
5
5
  "type": "module",
6
6
  "main": "dist/node/index.js",
@@ -86,13 +86,13 @@
86
86
  ],
87
87
  "dependencies": {
88
88
  "jose": "6.2.3",
89
- "@robota-sdk/agent-core": "3.0.0-beta.81",
90
- "@robota-sdk/agent-interface-analytics": "3.0.0-beta.81",
91
- "@robota-sdk/agent-interface-execution": "3.0.0-beta.81",
92
- "@robota-sdk/agent-interface-command": "3.0.0-beta.81",
93
- "@robota-sdk/agent-interface-session": "3.0.0-beta.81",
94
- "@robota-sdk/agent-interface-transport": "3.0.0-beta.81",
95
- "@robota-sdk/agent-interface-session-mobility": "3.0.0-beta.81"
89
+ "@robota-sdk/agent-core": "3.0.0-beta.82",
90
+ "@robota-sdk/agent-interface-analytics": "3.0.0-beta.82",
91
+ "@robota-sdk/agent-interface-command": "3.0.0-beta.82",
92
+ "@robota-sdk/agent-interface-execution": "3.0.0-beta.82",
93
+ "@robota-sdk/agent-interface-session": "3.0.0-beta.82",
94
+ "@robota-sdk/agent-interface-transport": "3.0.0-beta.82",
95
+ "@robota-sdk/agent-interface-session-mobility": "3.0.0-beta.82"
96
96
  },
97
97
  "devDependencies": {
98
98
  "rimraf": "^5.0.10",
@@ -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
+ });