mandala-computer-mcp 0.1.1 → 0.4.0

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 (72) hide show
  1. package/README.md +139 -24
  2. package/dist/api.d.ts +19 -6
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +355 -76
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +113 -8
  8. package/dist/cli.js.map +1 -1
  9. package/dist/errors.d.ts +100 -12
  10. package/dist/errors.d.ts.map +1 -1
  11. package/dist/errors.js +169 -29
  12. package/dist/errors.js.map +1 -1
  13. package/dist/events.d.ts +45 -4
  14. package/dist/events.d.ts.map +1 -1
  15. package/dist/events.js +422 -114
  16. package/dist/events.js.map +1 -1
  17. package/dist/format.d.ts +48 -0
  18. package/dist/format.d.ts.map +1 -1
  19. package/dist/format.js +111 -4
  20. package/dist/format.js.map +1 -1
  21. package/dist/http-body.d.ts +17 -0
  22. package/dist/http-body.d.ts.map +1 -0
  23. package/dist/http-body.js +48 -0
  24. package/dist/http-body.js.map +1 -0
  25. package/dist/http.d.ts.map +1 -1
  26. package/dist/http.js +177 -51
  27. package/dist/http.js.map +1 -1
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/limits.d.ts +17 -0
  33. package/dist/limits.d.ts.map +1 -0
  34. package/dist/limits.js +17 -0
  35. package/dist/limits.js.map +1 -0
  36. package/dist/paths.d.ts +30 -20
  37. package/dist/paths.d.ts.map +1 -1
  38. package/dist/paths.js +89 -25
  39. package/dist/paths.js.map +1 -1
  40. package/dist/poll.d.ts +107 -0
  41. package/dist/poll.d.ts.map +1 -0
  42. package/dist/poll.js +233 -0
  43. package/dist/poll.js.map +1 -0
  44. package/dist/server.d.ts +1 -1
  45. package/dist/server.d.ts.map +1 -1
  46. package/dist/server.js +2 -1
  47. package/dist/server.js.map +1 -1
  48. package/dist/tools/agent.d.ts.map +1 -1
  49. package/dist/tools/agent.js +72 -5
  50. package/dist/tools/agent.js.map +1 -1
  51. package/dist/tools/computers.d.ts.map +1 -1
  52. package/dist/tools/computers.js +559 -233
  53. package/dist/tools/computers.js.map +1 -1
  54. package/dist/tools/events.d.ts.map +1 -1
  55. package/dist/tools/events.js +359 -69
  56. package/dist/tools/events.js.map +1 -1
  57. package/dist/tools/guest.d.ts.map +1 -1
  58. package/dist/tools/guest.js +234 -33
  59. package/dist/tools/guest.js.map +1 -1
  60. package/dist/tools/input.d.ts.map +1 -1
  61. package/dist/tools/input.js +92 -8
  62. package/dist/tools/input.js.map +1 -1
  63. package/dist/tools/snapshots.d.ts.map +1 -1
  64. package/dist/tools/snapshots.js +501 -33
  65. package/dist/tools/snapshots.js.map +1 -1
  66. package/dist/tools/templates.d.ts.map +1 -1
  67. package/dist/tools/templates.js +61 -26
  68. package/dist/tools/templates.js.map +1 -1
  69. package/dist/tools/webhooks.d.ts.map +1 -1
  70. package/dist/tools/webhooks.js +116 -17
  71. package/dist/tools/webhooks.js.map +1 -1
  72. package/package.json +3 -2
package/dist/api.js CHANGED
@@ -1,5 +1,5 @@
1
- import { Agent, fetch as undiciFetch } from 'undici';
2
- import { CancelledError, ConnectivityError, ConnectivityInterruptedError, errorForStatus, MandalaError, RangeNotSatisfiableError, RateLimitError, } from './errors.js';
1
+ import { Agent, Headers as UndiciHeaders, fetch as undiciFetch } from 'undici';
2
+ import { CancelledError, ConnectivityError, ConnectivityInterruptedError, errorForStatus, MandalaError, RangeNotSatisfiableError, RedirectError, } from './errors.js';
3
3
  export const DEFAULT_BASE_URL = 'https://app.mandala.computer/api/v1';
4
4
  /** Anthropic's own key, forwarded for the one route that runs a model. */
5
5
  export const MODEL_KEY_HEADER = 'X-Model-Key';
@@ -11,14 +11,28 @@ export const MODEL_KEY_HEADER = 'X-Model-Key';
11
11
  * until the process runs out of memory.
12
12
  */
13
13
  const MAX_SSE_BUFFER = 8 * 1024 * 1024;
14
- /** Finite response-body ceilings for the two paths that decode text. */
15
- const MAX_JSON_BODY_BYTES = 16 * 1024 * 1024;
14
+ /**
15
+ * Finite response-body ceilings for the two paths that decode text.
16
+ *
17
+ * The JSON one is sized off the largest legitimate response on any route, which
18
+ * is an exec answer (OPL-4542). The guest agent caps its capture at 16 MiB PER
19
+ * STREAM, and both streams now travel as base64 — four characters for every
20
+ * three bytes — so a foreground command that filled both arrives as about
21
+ * 42.7 MiB of JSON. This used to be 16 MiB, sized when those fields were JSON
22
+ * strings of the decoded bytes, and a body over it is not truncated but
23
+ * REFUSED: the model would have lost the whole answer, truncation sentence
24
+ * included, somewhere north of 12 MiB of output. Under the platform's own
25
+ * 64 MiB transfer cap, and still finite, which is the only thing this guard is
26
+ * for — it exists to refuse an unbounded body, not to set a policy on a large
27
+ * one.
28
+ */
29
+ const MAX_JSON_BODY_BYTES = 48 * 1024 * 1024;
16
30
  const MAX_ERROR_BODY_BYTES = 1024 * 1024;
17
31
  /**
18
- * The longest guest exec waits 300 seconds before it answers. Node's bundled
19
- * fetch also gives response headers 300 seconds by default, so the client can
20
- * lose that race while the command is still finishing in the guest. Keep the
21
- * public exec limit and give the platform enough time to report its timeout.
32
+ * The longest foreground guest exec waits 600 seconds before it answers.
33
+ * Node's bundled fetch gives response headers 300 seconds by default, so the
34
+ * client can lose that race while the command is still finishing in the guest.
35
+ * Allow 30 seconds beyond the public exec limit for the timeout response.
22
36
  *
23
37
  * The body is a different clock. undici's default `bodyTimeout` is 300 seconds
24
38
  * of silence *between chunks*, and `run_agent` SSE (or a long exec that has
@@ -27,10 +41,10 @@ const MAX_ERROR_BODY_BYTES = 1024 * 1024;
27
41
  * disables it: a quiet gap is not a dead connection, and the caller's
28
42
  * AbortSignal is what ends a request nobody is waiting for.
29
43
  */
30
- export const PLATFORM_HEADERS_TIMEOUT_MS = 330_000;
44
+ export const PLATFORM_HEADERS_TIMEOUT_MS = 630_000;
31
45
  /** Disabled. A finite idle limit is what used to kill a quiet SSE stream. */
32
46
  export const PLATFORM_BODY_TIMEOUT_MS = 0;
33
- const PLATFORM_DISPATCHER = new Agent({
47
+ export const PLATFORM_DISPATCHER = new Agent({
34
48
  headersTimeout: PLATFORM_HEADERS_TIMEOUT_MS,
35
49
  bodyTimeout: PLATFORM_BODY_TIMEOUT_MS,
36
50
  });
@@ -105,7 +119,7 @@ export class Api {
105
119
  parsed = new URL(baseUrl);
106
120
  }
107
121
  catch {
108
- throw new MandalaError(`not a valid base URL: ${baseUrl}. Set MANDALA_BASE_URL to an absolute http(s) URL, e.g. ${DEFAULT_BASE_URL}`);
122
+ throw new MandalaError(`not a valid base URL. Set MANDALA_BASE_URL to an absolute http(s) URL, e.g. ${DEFAULT_BASE_URL}`);
109
123
  }
110
124
  // The scheme the message already promised. `new URL` alone accepts
111
125
  // `file:`, `ftp:` and anything else with a colon in it, so a typo that
@@ -113,7 +127,7 @@ export class Api {
113
127
  // the protocol — a message about the request, in a place that was supposed
114
128
  // to be about the setting.
115
129
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
116
- throw new MandalaError(`not an http(s) base URL: ${baseUrl}. Set MANDALA_BASE_URL to an absolute http(s) URL, e.g. ${DEFAULT_BASE_URL}`);
130
+ throw new MandalaError(`not an http(s) base URL. Set MANDALA_BASE_URL to an absolute http(s) URL, e.g. ${DEFAULT_BASE_URL}`);
117
131
  }
118
132
  // Normalised as a URL rather than as a string. `${base}/${path}` looked
119
133
  // equivalent and is not, because a base may carry a query — a tenant or an
@@ -126,9 +140,8 @@ export class Api {
126
140
  parsed.pathname = parsed.pathname.replace(/\/+$/, '');
127
141
  this.#base = parsed;
128
142
  // Still the string that was given, minus the trailing slashes it was always
129
- // stripped of — this is what error messages name and what `with` re-parses,
130
- // and changing its spelling would change what a reader is told they
131
- // configured.
143
+ // stripped of — this is what `with` re-parses, and changing its spelling
144
+ // would change the configured destination.
132
145
  this.baseUrl = baseUrl.replace(/\/+$/, '');
133
146
  this.#apiKey = apiKey;
134
147
  this.#signal = signal;
@@ -194,6 +207,8 @@ export class Api {
194
207
  body = JSON.stringify(opts.body);
195
208
  }
196
209
  const signal = opts.signal ?? this.#signal;
210
+ const requested = this.#url(path, opts.query);
211
+ const fetchRequest = platformFetch();
197
212
  let resp;
198
213
  try {
199
214
  // `dispatcher` is Node/undici's extension to RequestInit. It is kept on
@@ -203,9 +218,16 @@ export class Api {
203
218
  headers,
204
219
  body,
205
220
  signal,
221
+ // Answered, not followed. `isTransientForPoll` has always documented
222
+ // this client as one that does not follow redirects — the `>= 500` rule
223
+ // is argued from it — and the default `'follow'` quietly made that
224
+ // false: up to twenty hops, with the bearer still attached on the
225
+ // same-origin ones, and an operator whose MANDALA_BASE_URL is wrong
226
+ // never finding out. See {@link RedirectError}.
227
+ redirect: 'manual',
206
228
  dispatcher: PLATFORM_DISPATCHER,
207
229
  };
208
- resp = await platformFetch()(this.#url(path, opts.query), init);
230
+ resp = await fetchRequest(requested, init);
209
231
  }
210
232
  catch (cause) {
211
233
  // Cancellation first, because it is not a connectivity failure and the
@@ -219,6 +241,11 @@ export class Api {
219
241
  if (isCancellation(cause, signal)) {
220
242
  throw cancellationError(method, path, 'before the platform answered');
221
243
  }
244
+ if (fetchRequest === undiciFetch &&
245
+ undiciRejectsLocally(requested, headers)) {
246
+ throw new MandalaError(`${method} /${path.replace(/^\/+/, '')} was rejected locally before it could be sent. ` +
247
+ 'Check the configured URL and request headers.');
248
+ }
222
249
  // Rewritten, because the raw one names the host and the failure a model
223
250
  // can act on is "the platform is not reachable", not a DNS error string.
224
251
  //
@@ -228,14 +255,44 @@ export class Api {
228
255
  // wire means the platform may have acted and the answer was lost. The
229
256
  // second says so, and the wording follows the class rather than the other
230
257
  // way round (OPL-3855).
231
- const detail = cause instanceof Error ? cause.message : String(cause);
232
258
  if (neverDispatched(cause)) {
233
- throw new ConnectivityError(`could not reach ${this.#base.origin}: ${detail}`);
259
+ throw new ConnectivityError(`could not reach ${this.#base.origin}`);
234
260
  }
235
261
  throw new ConnectivityInterruptedError(`${method} /${path.replace(/^\/+/, '')} to ${this.#base.origin} failed after the request ` +
236
- `was sent: ${detail}. It may have been received, so treat anything it would have ` +
262
+ `was sent. It may have been received, so treat anything it would have ` +
237
263
  'changed as unknown rather than undone.');
238
264
  }
265
+ // Before the general mapping, because a 3xx carries no error body to read
266
+ // and its one useful field is a HEADER. Left to `#error` it would arrive as
267
+ // a bare `HTTP 301`, which says nothing about the configuration to inspect.
268
+ if (resp.status >= 300 && resp.status < 400) {
269
+ // Resolved against the request, because `Location` is very often relative
270
+ // and `redirect: 'manual'` hands back the raw header. The resolved value
271
+ // is diagnostic only: it names the resource that redirected, not
272
+ // necessarily the API root callers should configure.
273
+ const raw = resp.headers.get('location');
274
+ let to = raw ?? undefined;
275
+ if (raw) {
276
+ try {
277
+ to = new URL(raw, requested).toString();
278
+ }
279
+ catch {
280
+ // A Location this client cannot parse is still worth repeating
281
+ // verbatim: the operator can see what the platform said.
282
+ to = raw;
283
+ }
284
+ }
285
+ // Cancelled before the throw, the way every other exit in this file
286
+ // cancels its reader. A 3xx body is nothing anybody wants, but an unread
287
+ // one holds its undici connection open until the GC gets to it — and the
288
+ // whole point of this branch is a misconfigured base URL, which means
289
+ // EVERY request takes it.
290
+ await resp.body?.cancel().catch(() => { });
291
+ throw new RedirectError(`${method} /${path.replace(/^\/+/, '')} was redirected (HTTP ${resp.status}${to ? ` to ${to}` : ', with no Location header'}). This client does not follow redirects. Verify that MANDALA_BASE_URL names the API ` +
292
+ `root that serves this request; ${to
293
+ ? 'the resource URL in Location is not itself a base URL to copy'
294
+ : 'the configured API root did not identify the resource directly'}. Retrying this unchanged gets the same answer.`, resp.status);
295
+ }
239
296
  if (!resp.ok)
240
297
  throw await this.#error(resp, method, path, signal);
241
298
  return resp;
@@ -291,24 +348,13 @@ export class Api {
291
348
  body = text;
292
349
  }
293
350
  }
294
- // The one status whose headers say more than its body does. `Content-Range:
295
- // bytes *\/<size>` carries the file's real length, and errorForStatus takes
296
- // no headers — deliberately, since every other status it maps is decided by
297
- // the number alone. So this one is built here, where the response is still
298
- // in hand, and the length rides on the error to whoever asked for the range.
351
+ const delay = retryAfterMs(resp.headers.get('retry-after'));
352
+ // Content-Range describes the file length, independently of Retry-After.
299
353
  if (resp.status === 416) {
300
354
  const total = parseContentRange(resp.headers.get('content-range'))?.total;
301
- return new RangeNotSatisfiableError(message, resp.status, body, total);
302
- }
303
- // The other one, for the same reason: `Retry-After` is a header, and it is
304
- // the platform saying how long to wait rather than leaving the wait tools
305
- // to guess. Built here while the response is still in hand; the BY_STATUS
306
- // entry covers a 429 reaching errorForStatus from anywhere else, without
307
- // the number.
308
- if (resp.status === 429) {
309
- return new RateLimitError(message, resp.status, body, retryAfterMs(resp.headers.get('retry-after')));
355
+ return new RangeNotSatisfiableError(message, resp.status, body, total, delay);
310
356
  }
311
- return errorForStatus(resp.status, message, body);
357
+ return errorForStatus(resp.status, message, body, delay);
312
358
  }
313
359
  /**
314
360
  * A JSON body, or nothing, or a named failure.
@@ -377,7 +423,7 @@ export class Api {
377
423
  * short 200 — but a caller that opts in gets the list plus `X-GC-Incomplete`,
378
424
  * and a header is only a warning if something reads it.
379
425
  *
380
- * It is the count of what the placement cache could account for, and it is
426
+ * It is the count of what the host cache could account for, and it is
381
427
  * legitimately `0`: a computer created during the outage was never cached
382
428
  * against the host now holding it. So presence is the signal and the number is
383
429
  * detail, which is why this returns `null` versus a number rather than a
@@ -415,7 +461,7 @@ export class Api {
415
461
  // because assuming is the exact failure the status exists to prevent, and
416
462
  // because nothing downstream can tell the difference afterwards.
417
463
  //
418
- // The platform always sends the header (`bytes %d-%d/%d` in server/api.go).
464
+ // The platform always sends the header, formatted `bytes %d-%d/%d`.
419
465
  // A hop in front of it that drops the header is the case this is for, and
420
466
  // the same one mandala-computer-typescript's toFileChunk refuses.
421
467
  if (resp.status === 206 && !window) {
@@ -424,9 +470,22 @@ export class Api {
424
470
  `(${resp.headers.get('content-range') ?? 'header absent'}), so where these bytes ` +
425
471
  'belong in the file is unknown');
426
472
  }
473
+ const span = window ? window.end - window.start + 1 : undefined;
474
+ if (span !== undefined && declared !== undefined && declared !== span) {
475
+ await resp.body?.cancel().catch(() => { });
476
+ throw new MandalaError(`${method} ${path} answered 206 with Content-Range ${resp.headers.get('content-range')} ` +
477
+ `for ${span} bytes but Content-Length ${declared}; refusing an inconsistent partial response`);
478
+ }
427
479
  const { bytes, truncated } = await readBody(method, path, opts.signal ?? this.#signal, async () => limit === undefined
428
480
  ? { bytes: new Uint8Array(await resp.arrayBuffer()), truncated: false }
429
481
  : await readAtMost(resp, limit));
482
+ if (span !== undefined &&
483
+ ((!truncated && bytes.length !== span) || (truncated && bytes.length >= span))) {
484
+ throw new MandalaError(`${method} ${path} answered 206 with Content-Range ${resp.headers.get('content-range')} ` +
485
+ `for ${span} bytes but the body ${truncated
486
+ ? `continued after its ${bytes.length}-byte local prefix`
487
+ : `ended at ${bytes.length} bytes`}; refusing an inconsistent partial response`);
488
+ }
430
489
  return {
431
490
  bytes,
432
491
  contentType,
@@ -436,18 +495,13 @@ export class Api {
436
495
  // number here is about the window: Content-Length is how long THIS body
437
496
  // is, and `bytes.length` is how much of it was kept. Reading either as
438
497
  // the file's size is how a caller decides it has the whole thing.
439
- totalBytes: window?.total !== undefined
498
+ totalBytes: window
440
499
  ? window.total
441
500
  : truncated
442
501
  ? declared !== undefined && declared > bytes.length
443
502
  ? declared
444
503
  : undefined
445
- : // A 206 whose Content-Range said `*`: the window arrived in full
446
- // and the file's length is still unknown, so this must not fall
447
- // through to `bytes.length`, which would call the window the file.
448
- window
449
- ? undefined
450
- : bytes.length,
504
+ : bytes.length,
451
505
  unrangeable: (resp.headers.get('accept-ranges') ?? '').trim().toLowerCase() === 'none',
452
506
  window,
453
507
  };
@@ -466,6 +520,8 @@ export class Api {
466
520
  });
467
521
  const contentType = mediaType(resp.headers.get('content-type'));
468
522
  if (contentType !== 'text/event-stream') {
523
+ // No reader owns this body yet; release a rejected response ourselves.
524
+ await resp.body?.cancel().catch(() => { });
469
525
  throw new MandalaError(`${method} ${path} expected text/event-stream, but the platform answered ${contentType}`);
470
526
  }
471
527
  if (!resp.body)
@@ -473,6 +529,10 @@ export class Api {
473
529
  const reader = resp.body.getReader();
474
530
  const decoder = new TextDecoder();
475
531
  let buffer = '';
532
+ // A boundary ending in CR is complete immediately. If that CR later gains
533
+ // an LF in the next network chunk, discard the LF as the optional second
534
+ // byte of the line ending that was already consumed.
535
+ let suppressLeadingLf = false;
476
536
  try {
477
537
  for (;;) {
478
538
  const { done, value } = await readBody(method, path, opts.signal ?? this.#signal, () => reader.read());
@@ -483,21 +543,26 @@ export class Api {
483
543
  // becomes CR-then-LF, each rewritten to its own LF, and the pair reads
484
544
  // as the blank line that ends an event — so a frame gets cut in half at
485
545
  // a boundary that was never in the stream.
486
- buffer += decoder.decode(value, { stream: true });
487
- // Events are separated by a blank line, in whichever of the three
488
- // terminators the sender chose: the spec allows CRLF, LF and lone CR,
489
- // and a proxy that reframes the stream is entitled to any of them.
546
+ let decoded = decoder.decode(value, { stream: true });
547
+ if (suppressLeadingLf && decoded) {
548
+ suppressLeadingLf = false;
549
+ if (decoded.startsWith('\n'))
550
+ decoded = decoded.slice(1);
551
+ }
552
+ buffer += decoded;
553
+ // Events are separated by a blank line. Each of its two line endings
554
+ // may be CRLF, LF, or lone CR, and a proxy that reframes the stream is
555
+ // entitled to mix them.
490
556
  // Matching only "\n\n" found no boundary at all in a CRLF stream, which
491
557
  // collapsed a whole run into one unparseable event and lost the result
492
558
  // of a run that had in fact succeeded.
493
559
  for (;;) {
494
- const sep = /\r?\n\r?\n|\r\r/.exec(buffer);
495
- // A tail of "\r\n\r" is deliberately not a boundary yet — the LF that
496
- // would complete it may be in the next read.
560
+ const sep = sseBoundary(buffer);
497
561
  if (!sep)
498
562
  break;
499
563
  const chunk = buffer.slice(0, sep.index);
500
- buffer = buffer.slice(sep.index + sep[0].length);
564
+ buffer = buffer.slice(sep.index + sep.length);
565
+ suppressLeadingLf = sep.suppressLeadingLf;
501
566
  const parsed = parseEvent(chunk);
502
567
  if (parsed)
503
568
  yield parsed;
@@ -518,6 +583,16 @@ export class Api {
518
583
  // simply dropped without this, rather than surfacing as the replacement
519
584
  // character that says something was lost.
520
585
  buffer += decoder.decode();
586
+ for (;;) {
587
+ const sep = sseBoundary(buffer);
588
+ if (!sep)
589
+ break;
590
+ const chunk = buffer.slice(0, sep.index);
591
+ buffer = buffer.slice(sep.index + sep.length);
592
+ const parsed = parseEvent(chunk);
593
+ if (parsed)
594
+ yield parsed;
595
+ }
521
596
  const tail = parseEvent(buffer);
522
597
  if (tail)
523
598
  yield tail;
@@ -527,6 +602,31 @@ export class Api {
527
602
  }
528
603
  }
529
604
  }
605
+ /**
606
+ * Does undici refuse this request shape before it can put anything on a wire?
607
+ *
608
+ * Its own validation errors quote the rejected header value or full URL. Those
609
+ * values can contain the credentials this client is responsible for keeping
610
+ * out of errors, so inspect the inputs instead of the exception text. The
611
+ * caller checks that undici itself was selected before using this result; a
612
+ * replacement fetch may support URL userinfo or validate headers differently.
613
+ *
614
+ * Kept deliberately narrow. An arbitrary TypeError from fetch does not prove a
615
+ * request stayed local, and treating one as safe to replay could duplicate a
616
+ * mutating operation.
617
+ */
618
+ function undiciRejectsLocally(requested, headers) {
619
+ const url = new URL(requested);
620
+ if (url.username || url.password)
621
+ return true;
622
+ try {
623
+ new UndiciHeaders(headers);
624
+ return false;
625
+ }
626
+ catch {
627
+ return true;
628
+ }
629
+ }
530
630
  /**
531
631
  * Was this rejection the caller hanging up, rather than the network?
532
632
  *
@@ -551,7 +651,7 @@ function isCancellation(_cause, signal) {
551
651
  * links have to be followed. Bounded, because a cause chain is user-reachable
552
652
  * data and nothing here needs to be robust to a cycle.
553
653
  */
554
- function* causes(err, depth = 0) {
654
+ export function* causes(err, depth = 0) {
555
655
  if (!err || typeof err !== 'object' || depth > 5)
556
656
  return;
557
657
  const e = err;
@@ -710,7 +810,8 @@ async function readBody(method, path, signal, read) {
710
810
  // lost is the answer. That is precisely the case `isTransient` must say no
711
811
  // to and the poll predicate must ride out (OPL-3855).
712
812
  if (isTransportFailure(cause)) {
713
- throw new ConnectivityInterruptedError(`could not finish reading ${method} /${path.replace(/^\/+/, '')}: ${cause instanceof Error ? cause.message : String(cause)}. The request was received, so treat anything it would have changed as ` +
813
+ throw new ConnectivityInterruptedError(`could not finish reading ${method} /${path.replace(/^\/+/, '')}. ` +
814
+ `The request was received, so treat anything it would have changed as ` +
714
815
  'unknown rather than undone.');
715
816
  }
716
817
  throw cause;
@@ -741,9 +842,9 @@ function mediaType(header) {
741
842
  * cannot disagree about the grammar, and `*` in either position comes back as
742
843
  * `undefined` rather than as a number nothing sent.
743
844
  *
744
- * A malformed header is nothing rather than a throw: it is metadata about a
745
- * body that already arrived, and failing a download over the label on it would
746
- * be a worse answer than the one this gives.
845
+ * A malformed header is nothing rather than a throw so each status can decide
846
+ * what absence means. A 206 is rejected because its byte positions would be
847
+ * unknown; a 416 remains useful even when it cannot report the optional size.
747
848
  */
748
849
  function parseContentRange(header) {
749
850
  if (!header)
@@ -759,12 +860,29 @@ function parseContentRange(header) {
759
860
  };
760
861
  const start = num(m[1]);
761
862
  const end = num(m[2]);
863
+ const total = num(m[3]);
864
+ // Numeric fields that do not fit safely cannot be treated as wildcards.
865
+ // Doing so would turn a precise but unusable claim into an unknown one and
866
+ // let callers continue with offsets JavaScript cannot represent exactly.
867
+ if ((m[1] && start === undefined) ||
868
+ (m[2] && end === undefined) ||
869
+ (m[3] !== '*' && total === undefined)) {
870
+ return undefined;
871
+ }
762
872
  // A window whose end precedes its start describes no bytes. Dropping the pair
763
873
  // rather than passing it on keeps `end - start + 1` from being negative in
764
874
  // every caller that trusts this.
765
875
  if (start !== undefined && end !== undefined && end < start)
766
876
  return undefined;
767
- return { start, end, total: num(m[3]) };
877
+ if (start !== undefined && end !== undefined) {
878
+ const span = end - start + 1;
879
+ if (!Number.isSafeInteger(span))
880
+ return undefined;
881
+ // A satisfied range cannot name bytes at or beyond the complete length.
882
+ if (total !== undefined && end >= total)
883
+ return undefined;
884
+ }
885
+ return { start, end, total };
768
886
  }
769
887
  /**
770
888
  * The longest delay `setTimeout` takes without wrapping.
@@ -795,14 +913,28 @@ const MAX_TIMER_MS = 2_147_483_647;
795
913
  * delta-seconds is not malformed enough to stop there: `Date.parse('-5')` is a
796
914
  * date in 2001, so it falls through to the branch below and lands on 0, which
797
915
  * is the same answer a date in the past gets and is why nothing worse happens.
916
+ *
917
+ * DECIMAL DIGITS for the first branch, not whatever `Number()` will take. The
918
+ * header's grammar is delta-seconds or an HTTP-date, and `0x10` and `1e3` are
919
+ * neither — but `Number()` reads them as 16 and 1000, so a broken or hostile
920
+ * intermediary could spell a sixteen-minute sleep in three characters and have
921
+ * a poll loop honour it as if the platform had asked. Gated the way
922
+ * {@link contentLength} below and `port()` in cli.ts already gate the same
923
+ * `Number()` footgun; anything else falls through to the date branch, which
924
+ * refuses it, and the loop keeps its own interval.
798
925
  */
799
926
  function retryAfterMs(header) {
800
927
  if (!header)
801
928
  return undefined;
802
- const seconds = Number(header);
929
+ const seconds = /^\d+$/.test(header.trim()) ? Number(header) : Number.NaN;
803
930
  if (Number.isFinite(seconds) && seconds >= 0)
804
931
  return Math.min(seconds * 1_000, MAX_TIMER_MS);
805
- const at = Date.parse(header);
932
+ const value = header.trim();
933
+ const httpDate = /^(?:[A-Za-z]{3}, \d{2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2} GMT|[A-Za-z]+, \d{2}-[A-Za-z]{3}-\d{2} \d{2}:\d{2}:\d{2} GMT|[A-Za-z]{3} [A-Za-z]{3} {1,2}\d{1,2} \d{2}:\d{2}:\d{2} \d{4})$/;
934
+ if (!httpDate.test(value))
935
+ return undefined;
936
+ // HTTP dates are always UTC, including asctime's timezone-free spelling.
937
+ const at = Date.parse(value.endsWith('GMT') ? value : `${value} GMT`);
806
938
  if (!Number.isFinite(at))
807
939
  return undefined;
808
940
  return Math.min(Math.max(at - Date.now(), 0), MAX_TIMER_MS);
@@ -877,6 +1009,38 @@ async function readTextAtMost(resp, limit) {
877
1009
  const { bytes, truncated } = await readAtMost(resp, limit);
878
1010
  return { text: new TextDecoder().decode(bytes), truncated };
879
1011
  }
1012
+ /** Find the next SSE blank line without confusing a split CRLF for two lines. */
1013
+ function sseBoundary(text) {
1014
+ const endingAt = (index, trailingCrCompletesBoundary = false) => {
1015
+ const char = text[index];
1016
+ if (char === '\n')
1017
+ return { length: 1, suppressLeadingLf: false };
1018
+ if (char !== '\r')
1019
+ return undefined;
1020
+ if (index + 1 === text.length) {
1021
+ return trailingCrCompletesBoundary ? { length: 1, suppressLeadingLf: true } : undefined;
1022
+ }
1023
+ return {
1024
+ length: text[index + 1] === '\n' ? 2 : 1,
1025
+ suppressLeadingLf: false,
1026
+ };
1027
+ };
1028
+ for (let index = 0; index < text.length; index++) {
1029
+ const first = endingAt(index);
1030
+ if (first === undefined)
1031
+ continue;
1032
+ const second = endingAt(index + first.length, true);
1033
+ if (second !== undefined) {
1034
+ return {
1035
+ index,
1036
+ length: first.length + second.length,
1037
+ suppressLeadingLf: second.suppressLeadingLf,
1038
+ };
1039
+ }
1040
+ index += first.length - 1;
1041
+ }
1042
+ return undefined;
1043
+ }
880
1044
  function parseEvent(chunk) {
881
1045
  let event = 'message';
882
1046
  const data = [];
@@ -902,31 +1066,146 @@ function parseEvent(chunk) {
902
1066
  return { event, data: joined };
903
1067
  }
904
1068
  }
1069
+ /**
1070
+ * Split a `Content-Disposition` header into its parameters, per the grammar in
1071
+ * RFC 6266 §4.1 and RFC 7230 §3.2.6 rather than by regex.
1072
+ *
1073
+ * Two attempts at this shipped a worse bug than the one they fixed, and both
1074
+ * failed at the same place: deciding what a `"` means without tracking where in
1075
+ * the grammar the reader is. A `"` is only a delimiter where a value begins.
1076
+ * Anywhere else — `note=a"b` — it is an ordinary character of a token, and a
1077
+ * reader that toggles on every quote it meets turns the rest of the header into
1078
+ * one run and loses the real `filename` after it.
1079
+ *
1080
+ * Inside a quoted string, `\` escapes the next character (`quoted-pair`), so a
1081
+ * `\"` does NOT close the value. A reader that stops at the first `"` it sees
1082
+ * lets a trailing `\"` smuggle a `; filename=` out of a value the sender
1083
+ * controls and into the parameter list.
1084
+ *
1085
+ * An unterminated quoted value runs to the end of the header. That is the one
1086
+ * decision here that is a judgment rather than the grammar — see the comment on
1087
+ * `filenameFrom` — and it is what keeps such a value's contents from being read
1088
+ * as parameters at all.
1089
+ */
1090
+ function dispositionParams(header) {
1091
+ const params = [];
1092
+ // The disposition-type comes first and is a bare token; parameters begin at
1093
+ // the first `;`. A header with no `;` has no parameters and no filename.
1094
+ let i = header.indexOf(';');
1095
+ if (i === -1)
1096
+ return params;
1097
+ while (i < header.length) {
1098
+ i += 1; // past the ';' that begins this parameter
1099
+ while (i < header.length && /\s/.test(header[i]))
1100
+ i += 1;
1101
+ const nameFrom = i;
1102
+ while (i < header.length && header[i] !== '=' && header[i] !== ';')
1103
+ i += 1;
1104
+ const name = header.slice(nameFrom, i).trim().toLowerCase();
1105
+ if (header[i] !== '=') {
1106
+ // A parameter with no `=` at all. Recorded so it cannot be mistaken for
1107
+ // the next one, and skipped.
1108
+ if (name)
1109
+ params.push({ name, value: '' });
1110
+ continue;
1111
+ }
1112
+ i += 1; // past the '='
1113
+ // BWS: RFC 7230 allows whitespace either side of the `=`, and a value that
1114
+ // begins after it is still that parameter's value.
1115
+ while (i < header.length && /[ \t]/.test(header[i]))
1116
+ i += 1;
1117
+ let value = '';
1118
+ if (header[i] === '"') {
1119
+ i += 1;
1120
+ let out = '';
1121
+ for (; i < header.length; i += 1) {
1122
+ const ch = header[i];
1123
+ if (ch === '\\' && i + 1 < header.length) {
1124
+ out += header[i + 1];
1125
+ i += 1;
1126
+ }
1127
+ else if (ch === '"') {
1128
+ i += 1;
1129
+ break;
1130
+ }
1131
+ else
1132
+ out += ch;
1133
+ }
1134
+ value = out;
1135
+ // Anything between the closing quote and the next `;` is not part of the
1136
+ // value and is not a parameter either.
1137
+ while (i < header.length && header[i] !== ';')
1138
+ i += 1;
1139
+ }
1140
+ else {
1141
+ const from = i;
1142
+ while (i < header.length && header[i] !== ';')
1143
+ i += 1;
1144
+ // A token value carries no delimiters, so the whitespace around it is the
1145
+ // header's formatting rather than the name: `filename=real.txt ; x=1`
1146
+ // named a file with a trailing space on it.
1147
+ value = header.slice(from, i).trim();
1148
+ }
1149
+ if (name)
1150
+ params.push({ name, value });
1151
+ }
1152
+ return params;
1153
+ }
905
1154
  /** The filename the platform put on a download, if it put one there. */
906
1155
  export function filenameFrom(disposition) {
907
1156
  if (!disposition)
908
1157
  return undefined;
909
- // Any charset and any language, not only `UTF-8''`. RFC 5987 writes this
910
- // value as charset, language, then the text, with the language ordinarily
911
- // empty — and matching only the empty spelling meant that both
912
- // `filename*=ISO-8859-1''…` and `filename*=UTF-8'en'…` were read by neither
913
- // branch — the plain form below cannot match either, since there is no
914
- // `filename=` in them — so a download the platform had named came back with
915
- // no name at all. Three groups, not two: the middle one is the language tag,
916
- // present or empty.
917
- const star = /filename\*=([^']*)'([^']*)'([^;]+)/i.exec(disposition);
918
- if (star) {
919
- // A stray `%` in a guest filename is legal on disk and makes this throw.
920
- // Letting it out would turn a download whose bytes already arrived intact
921
- // into a failure, over the label on it.
1158
+ // Parsed rather than matched. Every previous spelling of this read the header
1159
+ // with one regex, and each one in turn found a `filename=` that belonged to
1160
+ // some other parameter's value — `inline; x-filename=q.txt` through a missing
1161
+ // parameter boundary, then `note="a; filename=evil.txt"` through a `;` inside
1162
+ // a quoted string. What the sender controls should not be able to add a
1163
+ // parameter the sender did not send.
1164
+ //
1165
+ // The judgment call, stated because the grammar does not make it: an
1166
+ // unterminated quoted value consumes the rest of the header. It is what the
1167
+ // WHATWG "collect an HTTP quoted string" algorithm does, and it is the only
1168
+ // reading under which `note="a; filename=evil.txt\"` — where the `\"` escapes
1169
+ // the quote that would have closed the value — yields no filename at all. The
1170
+ // cost is that a sloppy sender's unterminated value swallows a real
1171
+ // `filename` after it; the benefit is that a hostile one cannot smuggle a
1172
+ // fake one out. `filename="report.pdf` still works, because there the
1173
+ // unterminated value IS the filename.
1174
+ const params = dispositionParams(disposition);
1175
+ // RFC 8187: charset, language, then the text, with the language ordinarily
1176
+ // empty. Any charset and any language, not only `UTF-8''` — matching the
1177
+ // empty spelling alone meant `ISO-8859-1''…` and `UTF-8'en'…` were read by
1178
+ // neither branch, and a download the platform had named came back unnamed.
1179
+ // The FIRST USABLE one of each name, not the first one. A repeated parameter
1180
+ // is malformed and the platform never sends one, but the regex this replaced
1181
+ // walked on from an occurrence it could not read — `[^";]+` cannot match an
1182
+ // empty value — and answered from the next. Stopping at the first occurrence
1183
+ // instead means `filename=""; filename=real.txt` loses a name that was there,
1184
+ // which is a regression on an already-invalid header rather than a defence:
1185
+ // every parameter in this list is one the sender genuinely wrote at the top
1186
+ // level, since a smuggled one never becomes a parameter at all.
1187
+ for (const p of params) {
1188
+ if (p.name !== 'filename*')
1189
+ continue;
1190
+ const ext = /^[^']*'[^']*'([\s\S]*)$/.exec(p.value);
1191
+ // An empty `filename*` names nothing, so keep looking — at another
1192
+ // `filename*`, and then at the plain `filename` beside it. Either beats
1193
+ // answering with the empty string.
1194
+ if (!ext?.[1])
1195
+ continue;
922
1196
  try {
923
- return decodeURIComponent(star[3]);
1197
+ return decodeURIComponent(ext[1]);
924
1198
  }
925
1199
  catch {
926
- return star[3];
1200
+ // A stray `%` in a guest filename is legal on disk and makes this throw.
1201
+ // Letting it out would turn a download whose bytes already arrived intact
1202
+ // into a failure, over the label on it.
1203
+ return ext[1];
927
1204
  }
928
1205
  }
929
- const plain = /filename="?([^";]+)"?/i.exec(disposition);
930
- return plain ? plain[1] : undefined;
1206
+ for (const p of params)
1207
+ if (p.name === 'filename' && p.value)
1208
+ return p.value;
1209
+ return undefined;
931
1210
  }
932
1211
  //# sourceMappingURL=api.js.map