@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,530 @@
1
+ /**
2
+ * The file carrier: one file over one frame channel, in either direction, whatever the channel runs on.
3
+ *
4
+ * ## Shape
5
+ *
6
+ * The sender offers name, size and hash, and sends nothing more until the receiver accepts; the
7
+ * receiver decides (its operator's yes, somewhere to put the file) before a byte arrives. Content
8
+ * then moves in chunks, and the receiver checks the whole against the offer before it is kept. A
9
+ * mismatch discards it. There is no resume: a transfer that ends early is simply gone.
10
+ *
11
+ * ## Backpressure
12
+ *
13
+ * The receiver grants credit as it persists chunks, and the sender keeps at most a window of chunks
14
+ * unconfirmed. So neither side buffers more than the window, however fast the sender reads or slow
15
+ * the receiver's disk, and the bound holds on every channel without asking it about its buffers. A
16
+ * receiver that is sent past its credit ends the transfer: holding the excess is exactly what the
17
+ * window exists to prevent.
18
+ *
19
+ * ## Why chunks are base64 in JSON text
20
+ *
21
+ * Every channel it runs on carries text frames, and a frame is decoded as hostile input on arrival.
22
+ * One encoding for every frame keeps that decoding in one place; base64 costs a third of bandwidth,
23
+ * which the size limit already bounds.
24
+ */
25
+
26
+ import { createHash, type Hash } from 'node:crypto';
27
+
28
+ import type { IFileFrameChannel, IFileOffer } from '@robota-sdk/agent-interface-session-mobility';
29
+
30
+ /** Why a transfer did not end with the file kept. */
31
+ export type TFileTransferRefusal =
32
+ /** The receiving operator said no, could not be asked, or the connection does not carry files. */
33
+ | 'declined'
34
+ /** The file is over the size limit. */
35
+ | 'too-large'
36
+ /** The name is empty, or would leave the receiving directory. */
37
+ | 'bad-name'
38
+ /** A file of that name was already received; nothing is overwritten. */
39
+ | 'exists'
40
+ /** The receiving directory is not one this user owns outright, e.g. a symbolic link. */
41
+ | 'unsafe-path'
42
+ /** The content did not match the offered size or hash. The partial file is discarded. */
43
+ | 'integrity'
44
+ /** A frame broke the protocol. */
45
+ | 'protocol'
46
+ /** The channel ended first. */
47
+ | 'closed'
48
+ /** The other side went quiet. */
49
+ | 'timeout'
50
+ /** The receiver could not store the file. */
51
+ | 'unavailable';
52
+
53
+ /**
54
+ * The default size limit. A transfer does not resume, so a dropped connection costs the whole file
55
+ * again; a received file also sits on the receiver's disk until its operator removes it. The limit
56
+ * keeps both costs small while covering what is sent between one's own sessions: sources, logs,
57
+ * screenshots, a small archive.
58
+ */
59
+ export const DEFAULT_MAX_FILE_BYTES = 32 * 1024 * 1024;
60
+
61
+ /**
62
+ * Bytes of content per chunk. Small enough that a frame, base64 and envelope included, stays well
63
+ * inside the message size every channel it runs on carries.
64
+ */
65
+ export const FILE_CHUNK_BYTES = 16 * 1024;
66
+ /** Chunks the sender may have unconfirmed. */
67
+ export const FILE_CREDIT_WINDOW = 16;
68
+ /** A frame longer than a full chunk and its envelope is not one this protocol sends. */
69
+ const MAX_FRAME_CHARS = Math.ceil(FILE_CHUNK_BYTES / 3) * 4 + 512;
70
+ const MAX_NAME_CHARS = 1024;
71
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
72
+ const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
73
+ /** Between frames once content flows. */
74
+ const DEFAULT_IDLE_MS = 30_000;
75
+ /** For the receiving operator to answer. */
76
+ const DEFAULT_DECISION_MS = 5 * 60_000;
77
+ /** How long the receiver waits, after its last frame, for the sender to close. */
78
+ const LAST_WORD_MS = 5_000;
79
+
80
+ /** Where content comes from. `read` yields the content once, in order, in pieces of any size. */
81
+ export interface IFileSource {
82
+ readonly size: number;
83
+ read(): AsyncIterable<Uint8Array>;
84
+ }
85
+
86
+ /** Where received content goes until it is verified; nothing is kept unless `commit` runs. */
87
+ export interface IFileSink {
88
+ write(chunk: Uint8Array): Promise<void>;
89
+ /** Keep the verified file. Resolves with where it is. */
90
+ commit(): Promise<string>;
91
+ /** Drop what was written. */
92
+ discard(): Promise<void>;
93
+ }
94
+
95
+ /** The receiver's decision on an offer: somewhere to put it, or why not. */
96
+ export type TFileAdmission =
97
+ | { readonly sink: IFileSink }
98
+ | { readonly refused: TFileTransferRefusal; readonly detail?: string };
99
+
100
+ export type TFileSendOutcome =
101
+ | { readonly ok: true }
102
+ | { readonly ok: false; readonly reason: TFileTransferRefusal; readonly detail?: string };
103
+
104
+ export type TFileReceiveOutcome =
105
+ | { readonly ok: true; readonly offer: IFileOffer; readonly location: string }
106
+ | {
107
+ readonly ok: false;
108
+ readonly reason: TFileTransferRefusal;
109
+ readonly detail?: string;
110
+ readonly offer?: IFileOffer;
111
+ };
112
+
113
+ type TFrame =
114
+ | ({ readonly t: 'file-offer' } & IFileOffer)
115
+ | { readonly t: 'file-accept' }
116
+ | { readonly t: 'file-refuse'; readonly reason: TFileTransferRefusal; readonly detail?: string }
117
+ | { readonly t: 'file-chunk'; readonly seq: number; readonly data: string }
118
+ | { readonly t: 'file-credit'; readonly upTo: number }
119
+ | { readonly t: 'file-end'; readonly chunks: number }
120
+ | { readonly t: 'file-done' };
121
+
122
+ const REFUSALS: ReadonlySet<string> = new Set<TFileTransferRefusal>([
123
+ 'declined',
124
+ 'too-large',
125
+ 'bad-name',
126
+ 'exists',
127
+ 'unsafe-path',
128
+ 'integrity',
129
+ 'protocol',
130
+ 'closed',
131
+ 'timeout',
132
+ 'unavailable',
133
+ ]);
134
+
135
+ /** Code points that could repaint a terminal, hide text or reorder it. */
136
+ const UNPRINTABLE_RANGES: readonly (readonly [number, number])[] = [
137
+ [0x00, 0x1f],
138
+ [0x7f, 0x9f],
139
+ [0x200b, 0x200f],
140
+ [0x2028, 0x202e],
141
+ [0x2066, 0x2069],
142
+ [0xfeff, 0xfeff],
143
+ ];
144
+
145
+ function printable(text: string): string {
146
+ let out = '';
147
+ for (const char of text) {
148
+ const code = char.codePointAt(0) ?? 0;
149
+ out += UNPRINTABLE_RANGES.some(([low, high]) => code >= low && code <= high) ? ' ' : char;
150
+ }
151
+ return out;
152
+ }
153
+
154
+ function isCount(value: unknown): value is number {
155
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
156
+ }
157
+
158
+ /** Decode one frame as hostile input: anything not exactly a frame of this protocol is `undefined`. */
159
+ function decodeFrame(text: string): TFrame | undefined {
160
+ if (text.length > MAX_FRAME_CHARS) return undefined;
161
+ let value: unknown;
162
+ try {
163
+ value = JSON.parse(text);
164
+ } catch {
165
+ return undefined;
166
+ }
167
+ if (typeof value !== 'object' || value === null) return undefined;
168
+ const frame = value as Record<string, unknown>;
169
+ switch (frame['t']) {
170
+ case 'file-offer': {
171
+ const { transferId, name, size, sha256 } = frame;
172
+ if (typeof transferId !== 'string' || transferId.length === 0 || transferId.length > 128) {
173
+ return undefined;
174
+ }
175
+ if (typeof name !== 'string' || name.length > MAX_NAME_CHARS) return undefined;
176
+ if (!isCount(size) || typeof sha256 !== 'string' || !SHA256_HEX.test(sha256)) {
177
+ return undefined;
178
+ }
179
+ return { t: 'file-offer', transferId, name, size, sha256 };
180
+ }
181
+ case 'file-accept':
182
+ return { t: 'file-accept' };
183
+ case 'file-refuse': {
184
+ const reason = frame['reason'];
185
+ const detail = frame['detail'];
186
+ if (typeof reason !== 'string' || !REFUSALS.has(reason)) return undefined;
187
+ return {
188
+ t: 'file-refuse',
189
+ reason: reason as TFileTransferRefusal,
190
+ // The peer's words, shown to an operator and handed to a model: printable text only.
191
+ ...(typeof detail === 'string' ? { detail: printable(detail).slice(0, 500) } : {}),
192
+ };
193
+ }
194
+ case 'file-chunk': {
195
+ const { seq, data } = frame;
196
+ // An empty chunk moves nothing and would let a sender hold an approved transfer open forever.
197
+ if (!isCount(seq) || typeof data !== 'string' || data === '' || !BASE64.test(data)) {
198
+ return undefined;
199
+ }
200
+ return { t: 'file-chunk', seq, data };
201
+ }
202
+ case 'file-credit':
203
+ return isCount(frame['upTo']) ? { t: 'file-credit', upTo: frame['upTo'] } : undefined;
204
+ case 'file-end':
205
+ return isCount(frame['chunks']) ? { t: 'file-end', chunks: frame['chunks'] } : undefined;
206
+ case 'file-done':
207
+ return { t: 'file-done' };
208
+ default:
209
+ return undefined;
210
+ }
211
+ }
212
+
213
+ class TransferEnded extends Error {
214
+ constructor(
215
+ readonly reason: TFileTransferRefusal,
216
+ readonly detail?: string,
217
+ ) {
218
+ super(detail ?? reason);
219
+ }
220
+ }
221
+
222
+ /**
223
+ * The frames of one channel, taken one at a time. Frames beyond `capacity` waiting to be taken are a
224
+ * peer ignoring the protocol's pacing, and end the transfer rather than growing the queue.
225
+ */
226
+ class FrameReader {
227
+ private readonly queue: TFrame[] = [];
228
+ private waiter?: { resolve: (frame: TFrame) => void; reject: (error: TransferEnded) => void };
229
+ private failure?: TransferEnded;
230
+ private readonly stops: (() => void)[] = [];
231
+
232
+ constructor(
233
+ channel: IFileFrameChannel,
234
+ private readonly capacity: number,
235
+ ) {
236
+ this.stops.push(
237
+ channel.onFrame((text) => {
238
+ const frame = decodeFrame(text);
239
+ if (frame === undefined) {
240
+ this.fail(new TransferEnded('protocol', 'a frame could not be read'));
241
+ return;
242
+ }
243
+ if (this.waiter !== undefined) {
244
+ const { resolve } = this.waiter;
245
+ this.waiter = undefined;
246
+ resolve(frame);
247
+ return;
248
+ }
249
+ if (this.queue.length >= this.capacity) {
250
+ this.fail(new TransferEnded('protocol', 'the peer sent past its credit'));
251
+ return;
252
+ }
253
+ this.queue.push(frame);
254
+ }),
255
+ channel.onClose(() => this.fail(new TransferEnded('closed', 'the channel closed'))),
256
+ );
257
+ }
258
+
259
+ private fail(error: TransferEnded): void {
260
+ if (this.failure !== undefined) return;
261
+ this.failure = error;
262
+ // A peer that broke the protocol is not read further; frames it queued are not acted on.
263
+ if (error.reason === 'protocol') this.queue.length = 0;
264
+ const waiter = this.waiter;
265
+ this.waiter = undefined;
266
+ waiter?.reject(error);
267
+ }
268
+
269
+ /** The next frame, or the reason there will be none. */
270
+ next(timeoutMs: number): Promise<TFrame> {
271
+ const queued = this.queue.shift();
272
+ if (queued !== undefined) return Promise.resolve(queued);
273
+ if (this.failure !== undefined) return Promise.reject(this.failure);
274
+ return new Promise<TFrame>((resolve, reject) => {
275
+ const timer = setTimeout(() => {
276
+ this.waiter = undefined;
277
+ reject(new TransferEnded('timeout', `nothing arrived within ${timeoutMs}ms`));
278
+ }, timeoutMs);
279
+ timer.unref?.();
280
+ this.waiter = {
281
+ resolve: (frame) => {
282
+ clearTimeout(timer);
283
+ resolve(frame);
284
+ },
285
+ reject: (error) => {
286
+ clearTimeout(timer);
287
+ reject(error);
288
+ },
289
+ };
290
+ });
291
+ }
292
+
293
+ /** Whether the channel already ended or broke the protocol. */
294
+ get ended(): boolean {
295
+ return this.failure !== undefined;
296
+ }
297
+
298
+ stop(): void {
299
+ for (const stop of this.stops.splice(0)) stop();
300
+ }
301
+ }
302
+
303
+ function send(channel: IFileFrameChannel, frame: TFrame): void {
304
+ try {
305
+ channel.send(JSON.stringify(frame));
306
+ } catch (error) {
307
+ throw new TransferEnded('closed', error instanceof Error ? error.message : String(error));
308
+ }
309
+ }
310
+
311
+ function ended(error: unknown): TransferEnded {
312
+ return error instanceof TransferEnded
313
+ ? error
314
+ : new TransferEnded('unavailable', error instanceof Error ? error.message : String(error));
315
+ }
316
+
317
+ /** Cut a source's content into chunks of exactly `size` bytes, the last one shorter. */
318
+ async function* rechunk(source: AsyncIterable<Uint8Array>, size: number): AsyncGenerator<Buffer> {
319
+ let pending = Buffer.alloc(0);
320
+ for await (const piece of source) {
321
+ pending = pending.length === 0 ? Buffer.from(piece) : Buffer.concat([pending, piece]);
322
+ while (pending.length >= size) {
323
+ yield pending.subarray(0, size);
324
+ pending = pending.subarray(size);
325
+ }
326
+ }
327
+ if (pending.length > 0) yield pending;
328
+ }
329
+
330
+ export interface ISendFileOptions {
331
+ readonly channel: IFileFrameChannel;
332
+ readonly offer: IFileOffer;
333
+ readonly source: IFileSource;
334
+ readonly chunkBytes?: number;
335
+ readonly window?: number;
336
+ readonly idleMs?: number;
337
+ /** How long the receiving operator may take to answer. */
338
+ readonly decisionMs?: number;
339
+ }
340
+
341
+ /**
342
+ * Offer a file and, once accepted, send it. Resolves when the receiver confirms it kept the file, or
343
+ * with why it did not. Closes the channel either way.
344
+ */
345
+ export async function sendFileOverChannel(options: ISendFileOptions): Promise<TFileSendOutcome> {
346
+ const { channel, offer, source } = options;
347
+ const chunkBytes = options.chunkBytes ?? FILE_CHUNK_BYTES;
348
+ const window = options.window ?? FILE_CREDIT_WINDOW;
349
+ const idleMs = options.idleMs ?? DEFAULT_IDLE_MS;
350
+ // The receiver sends one credit per chunk and one final frame; nothing else is legitimate.
351
+ const reader = new FrameReader(channel, window + 2);
352
+ try {
353
+ if (source.size !== offer.size) {
354
+ throw new TransferEnded('integrity', 'the source is not the size offered');
355
+ }
356
+ send(channel, { t: 'file-offer', ...offer });
357
+ const answer = await reader.next(options.decisionMs ?? DEFAULT_DECISION_MS);
358
+ if (answer.t === 'file-refuse') throw new TransferEnded(answer.reason, answer.detail);
359
+ if (answer.t !== 'file-accept') throw new TransferEnded('protocol', 'expected an answer');
360
+
361
+ let sent = 0;
362
+ let credited = 0;
363
+ let bytes = 0;
364
+ const take = async (): Promise<void> => {
365
+ const frame = await reader.next(idleMs);
366
+ if (frame.t === 'file-refuse') throw new TransferEnded(frame.reason, frame.detail);
367
+ if (frame.t !== 'file-credit' || frame.upTo <= credited || frame.upTo > sent) {
368
+ throw new TransferEnded('protocol', 'expected credit');
369
+ }
370
+ credited = frame.upTo;
371
+ };
372
+ for await (const chunk of rechunk(source.read(), chunkBytes)) {
373
+ bytes += chunk.length;
374
+ if (bytes > offer.size) throw new TransferEnded('integrity', 'the source grew while sending');
375
+ while (sent - credited >= window) await take();
376
+ send(channel, { t: 'file-chunk', seq: sent, data: chunk.toString('base64') });
377
+ sent += 1;
378
+ }
379
+ if (bytes !== offer.size)
380
+ throw new TransferEnded('integrity', 'the source shrank while sending');
381
+ send(channel, { t: 'file-end', chunks: sent });
382
+ for (;;) {
383
+ const frame = await reader.next(idleMs);
384
+ if (frame.t === 'file-done') return { ok: true };
385
+ if (frame.t === 'file-refuse') throw new TransferEnded(frame.reason, frame.detail);
386
+ if (frame.t !== 'file-credit' || frame.upTo <= credited || frame.upTo > sent) {
387
+ throw new TransferEnded('protocol', 'expected credit or completion');
388
+ }
389
+ credited = frame.upTo;
390
+ }
391
+ } catch (error) {
392
+ const end = ended(error);
393
+ return {
394
+ ok: false,
395
+ reason: end.reason,
396
+ ...(end.detail !== undefined ? { detail: end.detail } : {}),
397
+ };
398
+ } finally {
399
+ reader.stop();
400
+ channel.close();
401
+ }
402
+ }
403
+
404
+ export interface IReceiveFileOptions {
405
+ readonly channel: IFileFrameChannel;
406
+ /** Offers above this are refused without asking anyone. */
407
+ readonly maxBytes?: number;
408
+ /**
409
+ * Decide on a well-formed offer within the size limit: ask whoever must approve it, and open
410
+ * somewhere to put it. `signal` aborts when the sender goes away.
411
+ */
412
+ readonly admit: (offer: IFileOffer, signal: AbortSignal) => Promise<TFileAdmission>;
413
+ readonly window?: number;
414
+ readonly idleMs?: number;
415
+ }
416
+
417
+ /**
418
+ * Receive one file: the channel's first frame is the offer. Resolves with where the verified file was
419
+ * kept, or with why nothing was. Closes the channel either way.
420
+ */
421
+ export async function receiveFileOverChannel(
422
+ options: IReceiveFileOptions,
423
+ ): Promise<TFileReceiveOutcome> {
424
+ const { channel } = options;
425
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_FILE_BYTES;
426
+ const window = options.window ?? FILE_CREDIT_WINDOW;
427
+ const idleMs = options.idleMs ?? DEFAULT_IDLE_MS;
428
+ // The sender may be a window of chunks ahead of what was written, plus its end frame.
429
+ const reader = new FrameReader(channel, window + 1);
430
+ const gone = new AbortController();
431
+ const stopWatching = channel.onClose(() => gone.abort());
432
+ let offer: IFileOffer | undefined;
433
+ let sink: IFileSink | undefined;
434
+ let lastWord = true;
435
+ try {
436
+ const first = await reader.next(idleMs);
437
+ if (first.t !== 'file-offer') throw new TransferEnded('protocol', 'expected an offer');
438
+ offer = {
439
+ transferId: first.transferId,
440
+ name: first.name,
441
+ size: first.size,
442
+ sha256: first.sha256,
443
+ };
444
+ if (offer.size > maxBytes) {
445
+ throw new TransferEnded(
446
+ 'too-large',
447
+ `${offer.size} bytes is over the ${maxBytes}-byte limit`,
448
+ );
449
+ }
450
+ const admission = await options.admit(offer, gone.signal);
451
+ if ('refused' in admission) throw new TransferEnded(admission.refused, admission.detail);
452
+ sink = admission.sink;
453
+ if (reader.ended) throw new TransferEnded('closed', 'the sender left before the answer');
454
+ send(channel, { t: 'file-accept' });
455
+
456
+ const hash: Hash = createHash('sha256');
457
+ let next = 0;
458
+ let bytes = 0;
459
+ for (;;) {
460
+ const frame = await reader.next(idleMs);
461
+ if (frame.t === 'file-end') {
462
+ if (frame.chunks !== next || bytes !== offer.size) {
463
+ throw new TransferEnded('integrity', 'the content is not the size offered');
464
+ }
465
+ if (hash.digest('hex') !== offer.sha256) {
466
+ throw new TransferEnded('integrity', 'the content does not match the offered sha256');
467
+ }
468
+ const location = await sink.commit();
469
+ sink = undefined;
470
+ send(channel, { t: 'file-done' });
471
+ return { ok: true, offer, location };
472
+ }
473
+ if (frame.t !== 'file-chunk' || frame.seq !== next) {
474
+ throw new TransferEnded('protocol', 'expected the next chunk');
475
+ }
476
+ const data = Buffer.from(frame.data, 'base64');
477
+ bytes += data.length;
478
+ if (bytes > offer.size) throw new TransferEnded('integrity', 'more content than offered');
479
+ hash.update(data);
480
+ await sink.write(data);
481
+ next += 1;
482
+ send(channel, { t: 'file-credit', upTo: next });
483
+ }
484
+ } catch (error) {
485
+ const end = ended(error);
486
+ lastWord = end.reason !== 'protocol';
487
+ if (sink !== undefined) {
488
+ // allow-fallback: the transfer already failed for `end`; a failed cleanup must not replace why.
489
+ await sink.discard().catch(() => undefined);
490
+ }
491
+ if (end.reason !== 'closed' && end.reason !== 'timeout') {
492
+ try {
493
+ send(channel, {
494
+ t: 'file-refuse',
495
+ reason: end.reason,
496
+ ...(end.detail !== undefined ? { detail: end.detail } : {}),
497
+ });
498
+ } catch {
499
+ // allow-fallback: the sender is already gone; the outcome below says why.
500
+ }
501
+ }
502
+ return {
503
+ ok: false,
504
+ reason: end.reason,
505
+ ...(end.detail !== undefined ? { detail: end.detail } : {}),
506
+ ...(offer !== undefined ? { offer } : {}),
507
+ };
508
+ } finally {
509
+ // The sender closes once it has the last word; closing first could cut that word off.
510
+ if (lastWord) await closed(gone.signal, LAST_WORD_MS);
511
+ stopWatching();
512
+ reader.stop();
513
+ channel.close();
514
+ }
515
+ }
516
+
517
+ /** Resolves when `signal` aborts, or after `ms`. */
518
+ function closed(signal: AbortSignal, ms: number): Promise<void> {
519
+ if (signal.aborted) return Promise.resolve();
520
+ return new Promise<void>((resolve) => {
521
+ const timer = setTimeout(done, ms);
522
+ timer.unref?.();
523
+ signal.addEventListener('abort', done, { once: true });
524
+ function done(): void {
525
+ clearTimeout(timer);
526
+ signal.removeEventListener('abort', done);
527
+ resolve();
528
+ }
529
+ });
530
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * HANDOFF-001 (#1811): what actually gets built and sent, and how the destination knows it arrived
3
+ * whole.
4
+ *
5
+ * Session mobility decides whether a source can offer a session and what its inventory says.
6
+ * This Node-only module seals and verifies the exact payload bytes.
7
+ *
8
+ * ## Integrity is checked in a specific order
9
+ *
10
+ * Length first, then digest. A truncated transfer is the common failure and the cheap one to
11
+ * detect; hashing a short buffer to discover it was short wastes the work and, worse, reports it as
12
+ * a digest mismatch — which reads as corruption or tampering rather than a dropped connection.
13
+ */
14
+
15
+ import { createHash } from 'node:crypto';
16
+
17
+ import type { IInteractiveSessionRecord } from '@robota-sdk/agent-interface-session';
18
+ import type { IHandoffIntegrity } from '@robota-sdk/agent-interface-session-mobility';
19
+
20
+ /**
21
+ * Serialize a record and describe what was serialized.
22
+ *
23
+ * The string is returned alongside the integrity data because the digest is only meaningful for
24
+ * these exact bytes. A caller that re-serialized the record before sending could produce a
25
+ * different key order and a digest mismatch on a payload that is perfectly correct — a corruption
26
+ * report caused by the corruption check.
27
+ */
28
+ export function sealHandoffRecord(record: IInteractiveSessionRecord): {
29
+ serialized: string;
30
+ integrity: IHandoffIntegrity;
31
+ } {
32
+ const serialized = JSON.stringify(record);
33
+ const bytes = Buffer.from(serialized, 'utf8');
34
+ return {
35
+ serialized,
36
+ integrity: {
37
+ digest: createHash('sha256').update(bytes).digest('base64url'),
38
+ byteLength: bytes.byteLength,
39
+ },
40
+ };
41
+ }
42
+
43
+ /** Why a received payload was not accepted. */
44
+ export type TIntegrityFailure = 'truncated' | 'digest-mismatch';
45
+
46
+ export interface IIntegrityVerdict {
47
+ readonly intact: boolean;
48
+ readonly failure?: TIntegrityFailure;
49
+ /** What the manifest promised and what arrived, so a report can say more than "it failed". */
50
+ readonly expectedBytes?: number;
51
+ readonly actualBytes?: number;
52
+ }
53
+
54
+ /**
55
+ * Check a received payload against the manifest's integrity metadata.
56
+ *
57
+ * Nothing is parsed here. Parsing a payload to check it is well-formed would mean building the
58
+ * object graph before knowing whether the bytes are the ones that were sent, and a destination that
59
+ * has already parsed a corrupt payload has already spent the effort this check exists to avoid.
60
+ */
61
+ export function verifyHandoffPayload(
62
+ serialized: string,
63
+ integrity: IHandoffIntegrity,
64
+ ): IIntegrityVerdict {
65
+ const bytes = Buffer.from(serialized, 'utf8');
66
+ if (bytes.byteLength !== integrity.byteLength) {
67
+ return {
68
+ intact: false,
69
+ failure: 'truncated',
70
+ expectedBytes: integrity.byteLength,
71
+ actualBytes: bytes.byteLength,
72
+ };
73
+ }
74
+ const digest = createHash('sha256').update(bytes).digest('base64url');
75
+ if (digest !== integrity.digest) return { intact: false, failure: 'digest-mismatch' };
76
+ return { intact: true };
77
+ }
@@ -0,0 +1,45 @@
1
+ export {
2
+ bearerCredential,
3
+ credentialMatches,
4
+ mintTransportToken,
5
+ resolveAdmission,
6
+ } from './admission.js';
7
+ export { sealHandoffRecord, verifyHandoffPayload } from './handoff-manifest.js';
8
+ export type { IIntegrityVerdict, TIntegrityFailure } from './handoff-manifest.js';
9
+ export { createAccessTokenVerifier } from './access-token-verifier.js';
10
+ export type { IAccessTokenVerifierDeps } from './access-token-verifier.js';
11
+ export {
12
+ createBearerResourceServer,
13
+ describeProtectedResource,
14
+ parsePublicHttpsUrl,
15
+ refuseAccessToken,
16
+ refuseBearerToken,
17
+ serveProtectedResourceMetadata,
18
+ } from './bearer-resource-gate.js';
19
+ export type {
20
+ IBearerFailure,
21
+ IBearerRefusalRecord,
22
+ IBearerResourceServer,
23
+ IProtectedResource,
24
+ TBearerRefusal,
25
+ TRemoteAddressClass,
26
+ } from './bearer-resource-gate.js';
27
+ export {
28
+ DEFAULT_MAX_FILE_BYTES,
29
+ FILE_CHUNK_BYTES,
30
+ FILE_CREDIT_WINDOW,
31
+ receiveFileOverChannel,
32
+ sendFileOverChannel,
33
+ } from './file-transfer.js';
34
+ export type {
35
+ IFileSink,
36
+ IFileSource,
37
+ IReceiveFileOptions,
38
+ ISendFileOptions,
39
+ TFileAdmission,
40
+ TFileReceiveOutcome,
41
+ TFileSendOutcome,
42
+ TFileTransferRefusal,
43
+ } from './file-transfer.js';
44
+ export { attachStdioCarrier } from './stdio-carrier.js';
45
+ export type { IStdioCarrier, IStdioCarrierHandlers } from './stdio-carrier.js';