mandala-computer-mcp 0.1.1 → 0.1.2

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.

Potentially problematic release.


This version of mandala-computer-mcp might be problematic. Click here for more details.

Files changed (66) hide show
  1. package/README.md +102 -10
  2. package/dist/api.d.ts +19 -6
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +213 -31
  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 +60 -0
  10. package/dist/errors.d.ts.map +1 -1
  11. package/dist/errors.js +71 -2
  12. package/dist/errors.js.map +1 -1
  13. package/dist/events.d.ts +17 -2
  14. package/dist/events.d.ts.map +1 -1
  15. package/dist/events.js +104 -13
  16. package/dist/events.js.map +1 -1
  17. package/dist/format.d.ts +16 -0
  18. package/dist/format.d.ts.map +1 -1
  19. package/dist/format.js +11 -1
  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/paths.d.ts +22 -13
  33. package/dist/paths.d.ts.map +1 -1
  34. package/dist/paths.js +68 -16
  35. package/dist/paths.js.map +1 -1
  36. package/dist/poll.d.ts +103 -0
  37. package/dist/poll.d.ts.map +1 -0
  38. package/dist/poll.js +129 -0
  39. package/dist/poll.js.map +1 -0
  40. package/dist/server.d.ts +1 -1
  41. package/dist/server.js +1 -1
  42. package/dist/tools/agent.d.ts.map +1 -1
  43. package/dist/tools/agent.js +14 -2
  44. package/dist/tools/agent.js.map +1 -1
  45. package/dist/tools/computers.d.ts.map +1 -1
  46. package/dist/tools/computers.js +228 -49
  47. package/dist/tools/computers.js.map +1 -1
  48. package/dist/tools/events.d.ts.map +1 -1
  49. package/dist/tools/events.js +224 -39
  50. package/dist/tools/events.js.map +1 -1
  51. package/dist/tools/guest.d.ts.map +1 -1
  52. package/dist/tools/guest.js +223 -30
  53. package/dist/tools/guest.js.map +1 -1
  54. package/dist/tools/input.d.ts.map +1 -1
  55. package/dist/tools/input.js +29 -3
  56. package/dist/tools/input.js.map +1 -1
  57. package/dist/tools/snapshots.d.ts.map +1 -1
  58. package/dist/tools/snapshots.js +478 -20
  59. package/dist/tools/snapshots.js.map +1 -1
  60. package/dist/tools/templates.d.ts.map +1 -1
  61. package/dist/tools/templates.js +50 -11
  62. package/dist/tools/templates.js.map +1 -1
  63. package/dist/tools/webhooks.d.ts.map +1 -1
  64. package/dist/tools/webhooks.js +116 -17
  65. package/dist/tools/webhooks.js.map +1 -1
  66. package/package.json +1 -1
package/README.md CHANGED
@@ -19,7 +19,24 @@ the way you would treat a password.
19
19
  Node 20.3 or newer. There is nothing else to install: `npx` fetches the server
20
20
  the first time a client starts it.
21
21
 
22
- **Claude Code**
22
+ **Claude Code** — as a plugin, which installs the server and a skill together:
23
+
24
+ ```sh
25
+ export MANDALA_API_KEY=com_… # in the shell Claude Code starts from
26
+ /plugin marketplace add mandalacomputer/mcp
27
+ /plugin install mandala-computer@mandala
28
+ ```
29
+
30
+ The skill — [`plugin/skills/mandala-computer/SKILL.md`](plugin/skills/mandala-computer/SKILL.md)
31
+ — is the part the tools cannot say for themselves: when a cloud desktop is the
32
+ right answer at all, that it costs money until it is suspended or stopped, that
33
+ `run_agent` is usually the right level and a screenshot per click is not, and
34
+ which refusals are worth a second try. It is a description of *when and how*,
35
+ not a second client; once the server is installed it stays out of the way.
36
+ `MANDALA_MODEL_KEY`, if exported alongside, is passed through and turns on
37
+ `run_agent`.
38
+
39
+ Or the server on its own, with the key inline:
23
40
 
24
41
  ```sh
25
42
  claude mcp add mandala -e MANDALA_API_KEY=com_… -- npx -y mandala-computer-mcp
@@ -122,8 +139,14 @@ already supports for when they are.
122
139
  **A running computer costs money, and a forgotten one keeps costing it.**
123
140
  `create_computer` says so in its own description, and so does everything that
124
141
  starts a machine by a side door — `restore_snapshot` boots a stopped computer,
125
- `write_clipboard` resumes a suspended one, and each of those is charged like
126
- any other start. When a stretch of work is over, `suspend_computer` (a pause:
142
+ and `write_clipboard`, `read_file` and `cursor_position` each resume a suspended
143
+ one, because each has to reach the guest agent to do its job. All of them are
144
+ charged like any other start, and on a plan at its limit can come back 402
145
+ rather than a result. The two reads are the surprising half of that list, and
146
+ are why neither is annotated `readOnlyHint`: a host that auto-approves
147
+ read-only tools would otherwise wake and bill a machine with nobody asked.
148
+ `screenshot`, `read_clipboard` and `list_windows` genuinely do not start
149
+ anything and keep the hint. When a stretch of work is over, `suspend_computer` (a pause:
127
150
  `start_computer` brings the same session back in about a second) or
128
151
  `stop_computer` (a shutdown: the disk is kept, the session is not). Idle
129
152
  suspend catches the ones a model forgets, but only after 30 minutes untouched.
@@ -258,6 +281,44 @@ they are doing — it does not receive them and has no `verify`, because a
258
281
  server with no endpoint has nothing to verify. `list_webhook_deliveries` is
259
282
  where a delivery that ran out of retries shows up; nothing is dropped silently.
260
283
 
284
+ **A capture outlives the request that starts it.** `POST /computers/:id/snapshots`
285
+ answers `202` with a placeholder row and copies the disk afterwards, which takes
286
+ minutes and scales with how much has been written — longer than any HTTP request
287
+ survives. So `create_snapshot` polls: it holds the id the platform allocated
288
+ before the copy, watches `list_snapshots` for that row to stop reading
289
+ `capturing`, and answers with the finished snapshot. It waits for *not
290
+ capturing* rather than for `pending`, because replication can carry a small
291
+ snapshot straight on to `durable` between two polls; and it matches on the id
292
+ rather than on the newest row for the computer, because a scheduled capture
293
+ finishing in the same window makes that wrong on exactly the long captures where
294
+ it matters. `wait: false` hands back the id instead, for a caller that would
295
+ rather poll on its own schedule.
296
+
297
+ The three answers it can end on are kept apart deliberately. A capture that
298
+ lands is the snapshot. A capture that FAILS mid-copy leaves no snapshot and no
299
+ row — the `capturing` row simply disappears, and that absence is the only signal
300
+ there is, which is why an incomplete listing is never allowed to decide it. A
301
+ wait that runs out says the capture is still running and names the id to follow,
302
+ because that one asks for a look rather than for another attempt.
303
+
304
+ **A deletion outlives its request too, and reads the other way round.**
305
+ `DELETE /snapshots/:id` answers `202` and then detaches the dependent snapshots
306
+ and removes the stored objects. There is no state that means deleted, so
307
+ `delete_snapshot` polls for the row to **go** — the mirror of a capture, which
308
+ polls for a row to stay and change. A row that stays is one that STALLED, and it
309
+ sits in `deleting`, a state a bare listing hides: the poll asks with
310
+ `include=unfinished` for exactly that reason, since without it a half-deleted
311
+ snapshot is indistinguishable from a deleted one. The platform retries a stalled
312
+ deletion itself every fifteen minutes, so the give-up sentence says to watch
313
+ rather than to repeat.
314
+
315
+ Because absence is the success signal here, "Deleted" is said only off a listing
316
+ read **whole**. A row missing because a hypervisor did not answer is not a row
317
+ that is gone, and that mistake is unrecoverable in a way the others are not:
318
+ nobody goes looking for a snapshot they have been told was destroyed. A `409`
319
+ saying the snapshot is already being deleted is progress, not a fault — the
320
+ answer is to watch that deletion finish, never to go and delete something else.
321
+
261
322
  **A schedule says when, not how long.** `snapshot_schedule` sets the window a
262
323
  computer's automatic snapshot is taken in; `get_retention` is what says how many
263
324
  of them survive, and it takes no computer because the window belongs to the
@@ -304,9 +365,12 @@ nothing; `background: true` is the only thing that works. The abandoned command
304
365
  keeps running, so the call after one of these often reports the guest agent as
305
366
  busy — that is the first failure continuing, not a second one.
306
367
 
307
- The ceiling belongs to that proxy rather than to the platform, which is why
308
- `timeout_s` still accepts up to 300: a `MANDALA_BASE_URL` pointed at an origin
309
- reached without the proxy in front of it does not have one.
368
+ That roughly two-minute ceiling belongs to the hosted proxy. The server accepts
369
+ integer foreground `timeout_s` values from 1 through 600 seconds (default 30),
370
+ and the tool exposes that range for a `MANDALA_BASE_URL` reached without the
371
+ proxy. The HTTP client allows 630 seconds for response headers so the server can
372
+ report a 600-second timeout. Use `background: true` and poll the handle for
373
+ longer work.
310
374
 
311
375
  **`list_windows` sees what a screenshot cannot.** It is how you tell an
312
376
  application that failed to start from one that has not painted yet. Match on
@@ -362,11 +426,25 @@ simply has fewer rows, an unknown number missing and nothing marking the gap.
362
426
  The other two append a row marked `unreachable` for each thing they could not
363
427
  reach — but only for a key that spans the account. A WORKSPACE-SCOPED key gets
364
428
  no marked rows either, because naming the missing ids would mean reading them
365
- out of a placement cache that has no workspace column, and handing a confined
429
+ out of a host cache that has no workspace column, and handing a confined
366
430
  credential ids from the workspaces it is confined away from. For such a key all
367
431
  three listings are the `INCOMPLETE:` line and nothing else, which is why that
368
432
  line is written first and in prose.
369
433
 
434
+ **A computer has a lifecycle of its own, separate from what its guest is
435
+ doing.** `state` is the platform's record of whether the machine exists —
436
+ `live`, `deleting`, `deleted`, `lost`, or `unreachable` when a listing could not
437
+ confirm the row against its host — while `status` is the host's answer about the
438
+ guest. A row served from the record has the first and not the second, so
439
+ `list_computers` prints both when both are there: a computer can be running and
440
+ being deleted at once.
441
+
442
+ An unfiltered listing is `live`, `unreachable` and `deleting`. The two terminal
443
+ states are withheld from it, so `list_computers(state: 'deleted')` is the only
444
+ way a computer that has gone is ever shown — and an empty answer to a filtered
445
+ listing is a fact about the filter rather than about the account, which is what
446
+ it says rather than inviting you to create one.
447
+
370
448
  **Snapshots mid-deletion are billed but hidden.** A deletion that began and did
371
449
  not finish still holds objects and still counts against storage, and the default
372
450
  listing leaves it out — every ordinary caller is asking "what can I restore".
@@ -382,6 +460,10 @@ by default — the platform drops input on that socket, so it is safe to hand to
382
460
  somebody. `control: true` returns the full-control one, which is root-equivalent
383
461
  on that machine. Neither appears in any other tool's output, deliberately: a
384
462
  tool result lands in a model's context and from there in whatever captured it.
463
+ It is also why `get_desktop_url` carries no `readOnlyHint` even though its route
464
+ neither writes nor spends: hosts treat that hint as licence to call without
465
+ asking, so keeping it would let a model pass out control of a desktop with
466
+ nobody prompted.
385
467
 
386
468
  **Retiring a template cannot be undone, and takes more than it looks.**
387
469
  `retire_template` without a `version` retires **every** version of the name —
@@ -411,7 +493,17 @@ default request timeout is 60 seconds and only a progress notification can reset
411
493
  it — but the SDK resets it only for a caller that passed that option, so a client
412
494
  which merely accepts progress is still cancelled a minute into a fifteen-minute
413
495
  build. `get_build` is the answer for a client that cannot hold a request open:
414
- it reads once and returns. A build that *failed* is a normal answer from
496
+ it reads once and returns.
497
+
498
+ **The same applies to every tool here that waits.** `wait_for_computer`,
499
+ `move_computer`, `create_snapshot` and `delete_snapshot` all poll, and all report
500
+ progress on every poll — a changed line at once, an unchanged one on a ten-second
501
+ heartbeat, so the request stays open without flooding the client. A live capture
502
+ took 107 seconds and was cancelled at 60 before this existed, which turned every
503
+ carefully-worded answer about what had actually happened into a transport error.
504
+ Each of them has a way out for a client that cannot opt in: `wait: false` on the
505
+ two snapshot tools, `list_moves` after a move, a shorter `timeout_s` and a second
506
+ call on the wait. A build that *failed* is a normal answer from
415
507
  `watch_build`, not an error — it names the step that stopped it, which is the
416
508
  thing to fix. An `error` event is the *stream* failing and says nothing about the
417
509
  build, and the tool says so rather than letting a model rewrite a document that
@@ -472,7 +564,7 @@ naming the fix.
472
564
  | `MANDALA_BASE_URL` | Defaults to `https://app.mandala.computer/api/v1`. |
473
565
  | `MANDALA_COMPUTER_ID` | Bind a computer at startup, so `use_computer` is not needed. **stdio only** — under `--http` it is ignored rather than bound into every caller's session, since it names a machine on the operator's account. |
474
566
  | `MANDALA_MODEL_KEY` | An Anthropic key. Enables `run_agent`, which runs the platform's own loop on that key. **stdio only** — under `--http` each caller sends their own as `X-Model-Key`, and this variable is ignored. |
475
- | `MANDALA_NO_LIFECYCLE` | `1` withholds `create_computer`, `clone_computer`, `clone_snapshot`, `delete_computer` and `delete_snapshot` — every tool that makes a computer or destroys one. |
567
+ | `MANDALA_NO_LIFECYCLE` | `1`, `true`, `yes` or `on` withholds `create_computer`, `clone_computer`, `clone_snapshot`, `delete_computer` and `delete_snapshot` — every tool that makes a computer or destroys one. `0`, `false`, `no`, `off` or unset leaves them registered. Any other value is **refused at startup** rather than read as off: a typo here would otherwise leave those tools in place on a server whose operator believes they are gone. The `--no-lifecycle` flag reads the same vocabulary and refuses the same way, except that it has no spelling for *unset*: `--no-lifecycle=` is refused rather than ignored, so a launcher template whose variable did not expand stops instead of quietly leaving the tools registered. |
476
568
  | `PORT`, `HOST` | For `--http`. Default `3000`, `127.0.0.1`. |
477
569
  | `MANDALA_ALLOWED_HOSTS`, `MANDALA_ALLOWED_ORIGINS` | Comma-separated. Which `Host` and `Origin` values this server answers to. On a loopback bind the host list defaults to the address it was given, so DNS-rebinding protection is on without configuration; set this when serving under a name. |
478
570
 
@@ -526,7 +618,7 @@ platform team; the diff is enforced from the platform's own CI, which checks
526
618
  this repository out beside itself and runs the same script.
527
619
 
528
620
  ```
529
- check:surface — the mirror matches the platform (56 routes, 89 parameters, from …/mandala-computer).
621
+ check:surface — the mirror matches the platform (N routes, N parameters, from ).
530
622
  ```
531
623
 
532
624
  ## See also
package/dist/api.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { Agent } from 'undici';
1
2
  export declare const DEFAULT_BASE_URL = "https://app.mandala.computer/api/v1";
2
3
  /** Anthropic's own key, forwarded for the one route that runs a model. */
3
4
  export declare const MODEL_KEY_HEADER = "X-Model-Key";
@@ -54,10 +55,10 @@ export type Bytes = {
54
55
  unrangeable: boolean;
55
56
  };
56
57
  /**
57
- * The longest guest exec waits 300 seconds before it answers. Node's bundled
58
- * fetch also gives response headers 300 seconds by default, so the client can
59
- * lose that race while the command is still finishing in the guest. Keep the
60
- * public exec limit and give the platform enough time to report its timeout.
58
+ * The longest foreground guest exec waits 600 seconds before it answers.
59
+ * Node's bundled fetch gives response headers 300 seconds by default, so the
60
+ * client can lose that race while the command is still finishing in the guest.
61
+ * Allow 30 seconds beyond the public exec limit for the timeout response.
61
62
  *
62
63
  * The body is a different clock. undici's default `bodyTimeout` is 300 seconds
63
64
  * of silence *between chunks*, and `run_agent` SSE (or a long exec that has
@@ -66,9 +67,10 @@ export type Bytes = {
66
67
  * disables it: a quiet gap is not a dead connection, and the caller's
67
68
  * AbortSignal is what ends a request nobody is waiting for.
68
69
  */
69
- export declare const PLATFORM_HEADERS_TIMEOUT_MS = 330000;
70
+ export declare const PLATFORM_HEADERS_TIMEOUT_MS = 630000;
70
71
  /** Disabled. A finite idle limit is what used to kill a quiet SSE stream. */
71
72
  export declare const PLATFORM_BODY_TIMEOUT_MS = 0;
73
+ export declare const PLATFORM_DISPATCHER: Agent;
72
74
  /**
73
75
  * The fetch a platform request actually goes through, and why it is not simply
74
76
  * `fetch`.
@@ -160,7 +162,7 @@ export declare class Api {
160
162
  * short 200 — but a caller that opts in gets the list plus `X-GC-Incomplete`,
161
163
  * and a header is only a warning if something reads it.
162
164
  *
163
- * It is the count of what the placement cache could account for, and it is
165
+ * It is the count of what the host cache could account for, and it is
164
166
  * legitimately `0`: a computer created during the outage was never cached
165
167
  * against the host now holding it. So presence is the signal and the number is
166
168
  * detail, which is why this returns `null` versus a number rather than a
@@ -181,6 +183,17 @@ export declare class Api {
181
183
  */
182
184
  sse(method: string, path: string, opts?: RequestOptions): AsyncGenerator<SSEEvent>;
183
185
  }
186
+ /**
187
+ * Every error under one, including the ones a fetch hides two levels down.
188
+ *
189
+ * A rejected fetch is a `TypeError: fetch failed` whose `cause` is what
190
+ * actually went wrong, and on a dual-stack host that cause is an
191
+ * `AggregateError` holding one attempt per address. Neither the top error nor
192
+ * its immediate cause carries the code the classifiers below read, so both
193
+ * links have to be followed. Bounded, because a cause chain is user-reachable
194
+ * data and nothing here needs to be robust to a cycle.
195
+ */
196
+ export declare function causes(err: unknown, depth?: number): Generator<Record<string, unknown>>;
184
197
  /** The filename the platform put on a download, if it put one there. */
185
198
  export declare function filenameFrom(disposition: string | null): string | undefined;
186
199
  //# sourceMappingURL=api.d.ts.map
package/dist/api.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
package/dist/api.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Agent, fetch as undiciFetch } from 'undici';
2
- import { CancelledError, ConnectivityError, ConnectivityInterruptedError, errorForStatus, MandalaError, RangeNotSatisfiableError, RateLimitError, } from './errors.js';
2
+ import { CancelledError, ConnectivityError, ConnectivityInterruptedError, errorForStatus, MandalaError, RangeNotSatisfiableError, RateLimitError, 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
  });
@@ -195,6 +209,8 @@ export class Api {
195
209
  }
196
210
  const signal = opts.signal ?? this.#signal;
197
211
  let resp;
212
+ /** Kept so a relative `Location` can be resolved against what was asked. */
213
+ let requested = this.#base;
198
214
  try {
199
215
  // `dispatcher` is Node/undici's extension to RequestInit. It is kept on
200
216
  // a typed variable so the standard fetch signature can still be used.
@@ -203,9 +219,17 @@ export class Api {
203
219
  headers,
204
220
  body,
205
221
  signal,
222
+ // Answered, not followed. `isTransientForPoll` has always documented
223
+ // this client as one that does not follow redirects — the `>= 500` rule
224
+ // is argued from it — and the default `'follow'` quietly made that
225
+ // false: up to twenty hops, with the bearer still attached on the
226
+ // same-origin ones, and an operator whose MANDALA_BASE_URL is wrong
227
+ // never finding out. See {@link RedirectError}.
228
+ redirect: 'manual',
206
229
  dispatcher: PLATFORM_DISPATCHER,
207
230
  };
208
- resp = await platformFetch()(this.#url(path, opts.query), init);
231
+ requested = this.#url(path, opts.query);
232
+ resp = await platformFetch()(requested, init);
209
233
  }
210
234
  catch (cause) {
211
235
  // Cancellation first, because it is not a connectivity failure and the
@@ -236,6 +260,38 @@ export class Api {
236
260
  `was sent: ${detail}. It may have been received, so treat anything it would have ` +
237
261
  'changed as unknown rather than undone.');
238
262
  }
263
+ // Before the general mapping, because a 3xx carries no error body to read
264
+ // and its one useful field is a HEADER. Left to `#error` it would arrive as
265
+ // a bare `HTTP 301`, which says nothing about the value that has to change.
266
+ if (resp.status >= 300 && resp.status < 400) {
267
+ // Resolved against the request, because `Location` is very often relative
268
+ // and `redirect: 'manual'` hands back the raw header. "set
269
+ // MANDALA_BASE_URL to /api/v1/computers" names something that is not a URL
270
+ // and would not work — and naming the value to paste is the entire reason
271
+ // this branch exists rather than a bare `HTTP 301`.
272
+ const raw = resp.headers.get('location');
273
+ let to = raw ?? undefined;
274
+ if (raw) {
275
+ try {
276
+ to = new URL(raw, requested).toString();
277
+ }
278
+ catch {
279
+ // A Location this client cannot parse is still worth repeating
280
+ // verbatim: the operator can see what the platform said.
281
+ to = raw;
282
+ }
283
+ }
284
+ // Cancelled before the throw, the way every other exit in this file
285
+ // cancels its reader. A 3xx body is nothing anybody wants, but an unread
286
+ // one holds its undici connection open until the GC gets to it — and the
287
+ // whole point of this branch is a misconfigured base URL, which means
288
+ // EVERY request takes it.
289
+ await resp.body?.cancel().catch(() => { });
290
+ throw new RedirectError(`${method} /${path.replace(/^\/+/, '')} was redirected (HTTP ${resp.status}${to ? ` to ${to}` : ', with no Location header'}). This client does not follow redirects, because a redirect says the ` +
291
+ `address it was given is not the address in use — set MANDALA_BASE_URL to ` +
292
+ `${to ? 'that URL' : 'the URL the platform actually serves'} rather than relying on ` +
293
+ `a hop on every request. Retrying this unchanged gets the same answer.`, resp.status);
294
+ }
239
295
  if (!resp.ok)
240
296
  throw await this.#error(resp, method, path, signal);
241
297
  return resp;
@@ -377,7 +433,7 @@ export class Api {
377
433
  * short 200 — but a caller that opts in gets the list plus `X-GC-Incomplete`,
378
434
  * and a header is only a warning if something reads it.
379
435
  *
380
- * It is the count of what the placement cache could account for, and it is
436
+ * It is the count of what the host cache could account for, and it is
381
437
  * legitimately `0`: a computer created during the outage was never cached
382
438
  * against the host now holding it. So presence is the signal and the number is
383
439
  * detail, which is why this returns `null` versus a number rather than a
@@ -415,7 +471,7 @@ export class Api {
415
471
  // because assuming is the exact failure the status exists to prevent, and
416
472
  // because nothing downstream can tell the difference afterwards.
417
473
  //
418
- // The platform always sends the header (`bytes %d-%d/%d` in server/api.go).
474
+ // The platform always sends the header, formatted `bytes %d-%d/%d`.
419
475
  // A hop in front of it that drops the header is the case this is for, and
420
476
  // the same one mandala-computer-typescript's toFileChunk refuses.
421
477
  if (resp.status === 206 && !window) {
@@ -466,6 +522,8 @@ export class Api {
466
522
  });
467
523
  const contentType = mediaType(resp.headers.get('content-type'));
468
524
  if (contentType !== 'text/event-stream') {
525
+ // No reader owns this body yet; release a rejected response ourselves.
526
+ await resp.body?.cancel().catch(() => { });
469
527
  throw new MandalaError(`${method} ${path} expected text/event-stream, but the platform answered ${contentType}`);
470
528
  }
471
529
  if (!resp.body)
@@ -551,7 +609,7 @@ function isCancellation(_cause, signal) {
551
609
  * links have to be followed. Bounded, because a cause chain is user-reachable
552
610
  * data and nothing here needs to be robust to a cycle.
553
611
  */
554
- function* causes(err, depth = 0) {
612
+ export function* causes(err, depth = 0) {
555
613
  if (!err || typeof err !== 'object' || depth > 5)
556
614
  return;
557
615
  const e = err;
@@ -795,11 +853,20 @@ const MAX_TIMER_MS = 2_147_483_647;
795
853
  * delta-seconds is not malformed enough to stop there: `Date.parse('-5')` is a
796
854
  * date in 2001, so it falls through to the branch below and lands on 0, which
797
855
  * is the same answer a date in the past gets and is why nothing worse happens.
856
+ *
857
+ * DECIMAL DIGITS for the first branch, not whatever `Number()` will take. The
858
+ * header's grammar is delta-seconds or an HTTP-date, and `0x10` and `1e3` are
859
+ * neither — but `Number()` reads them as 16 and 1000, so a broken or hostile
860
+ * intermediary could spell a sixteen-minute sleep in three characters and have
861
+ * a poll loop honour it as if the platform had asked. Gated the way
862
+ * {@link contentLength} below and `port()` in cli.ts already gate the same
863
+ * `Number()` footgun; anything else falls through to the date branch, which
864
+ * refuses it, and the loop keeps its own interval.
798
865
  */
799
866
  function retryAfterMs(header) {
800
867
  if (!header)
801
868
  return undefined;
802
- const seconds = Number(header);
869
+ const seconds = /^\d+$/.test(header.trim()) ? Number(header) : Number.NaN;
803
870
  if (Number.isFinite(seconds) && seconds >= 0)
804
871
  return Math.min(seconds * 1_000, MAX_TIMER_MS);
805
872
  const at = Date.parse(header);
@@ -902,31 +969,146 @@ function parseEvent(chunk) {
902
969
  return { event, data: joined };
903
970
  }
904
971
  }
972
+ /**
973
+ * Split a `Content-Disposition` header into its parameters, per the grammar in
974
+ * RFC 6266 §4.1 and RFC 7230 §3.2.6 rather than by regex.
975
+ *
976
+ * Two attempts at this shipped a worse bug than the one they fixed, and both
977
+ * failed at the same place: deciding what a `"` means without tracking where in
978
+ * the grammar the reader is. A `"` is only a delimiter where a value begins.
979
+ * Anywhere else — `note=a"b` — it is an ordinary character of a token, and a
980
+ * reader that toggles on every quote it meets turns the rest of the header into
981
+ * one run and loses the real `filename` after it.
982
+ *
983
+ * Inside a quoted string, `\` escapes the next character (`quoted-pair`), so a
984
+ * `\"` does NOT close the value. A reader that stops at the first `"` it sees
985
+ * lets a trailing `\"` smuggle a `; filename=` out of a value the sender
986
+ * controls and into the parameter list.
987
+ *
988
+ * An unterminated quoted value runs to the end of the header. That is the one
989
+ * decision here that is a judgment rather than the grammar — see the comment on
990
+ * `filenameFrom` — and it is what keeps such a value's contents from being read
991
+ * as parameters at all.
992
+ */
993
+ function dispositionParams(header) {
994
+ const params = [];
995
+ // The disposition-type comes first and is a bare token; parameters begin at
996
+ // the first `;`. A header with no `;` has no parameters and no filename.
997
+ let i = header.indexOf(';');
998
+ if (i === -1)
999
+ return params;
1000
+ while (i < header.length) {
1001
+ i += 1; // past the ';' that begins this parameter
1002
+ while (i < header.length && /\s/.test(header[i]))
1003
+ i += 1;
1004
+ const nameFrom = i;
1005
+ while (i < header.length && header[i] !== '=' && header[i] !== ';')
1006
+ i += 1;
1007
+ const name = header.slice(nameFrom, i).trim().toLowerCase();
1008
+ if (header[i] !== '=') {
1009
+ // A parameter with no `=` at all. Recorded so it cannot be mistaken for
1010
+ // the next one, and skipped.
1011
+ if (name)
1012
+ params.push({ name, value: '' });
1013
+ continue;
1014
+ }
1015
+ i += 1; // past the '='
1016
+ // BWS: RFC 7230 allows whitespace either side of the `=`, and a value that
1017
+ // begins after it is still that parameter's value.
1018
+ while (i < header.length && /[ \t]/.test(header[i]))
1019
+ i += 1;
1020
+ let value = '';
1021
+ if (header[i] === '"') {
1022
+ i += 1;
1023
+ let out = '';
1024
+ for (; i < header.length; i += 1) {
1025
+ const ch = header[i];
1026
+ if (ch === '\\' && i + 1 < header.length) {
1027
+ out += header[i + 1];
1028
+ i += 1;
1029
+ }
1030
+ else if (ch === '"') {
1031
+ i += 1;
1032
+ break;
1033
+ }
1034
+ else
1035
+ out += ch;
1036
+ }
1037
+ value = out;
1038
+ // Anything between the closing quote and the next `;` is not part of the
1039
+ // value and is not a parameter either.
1040
+ while (i < header.length && header[i] !== ';')
1041
+ i += 1;
1042
+ }
1043
+ else {
1044
+ const from = i;
1045
+ while (i < header.length && header[i] !== ';')
1046
+ i += 1;
1047
+ // A token value carries no delimiters, so the whitespace around it is the
1048
+ // header's formatting rather than the name: `filename=real.txt ; x=1`
1049
+ // named a file with a trailing space on it.
1050
+ value = header.slice(from, i).trim();
1051
+ }
1052
+ if (name)
1053
+ params.push({ name, value });
1054
+ }
1055
+ return params;
1056
+ }
905
1057
  /** The filename the platform put on a download, if it put one there. */
906
1058
  export function filenameFrom(disposition) {
907
1059
  if (!disposition)
908
1060
  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.
1061
+ // Parsed rather than matched. Every previous spelling of this read the header
1062
+ // with one regex, and each one in turn found a `filename=` that belonged to
1063
+ // some other parameter's value `inline; x-filename=q.txt` through a missing
1064
+ // parameter boundary, then `note="a; filename=evil.txt"` through a `;` inside
1065
+ // a quoted string. What the sender controls should not be able to add a
1066
+ // parameter the sender did not send.
1067
+ //
1068
+ // The judgment call, stated because the grammar does not make it: an
1069
+ // unterminated quoted value consumes the rest of the header. It is what the
1070
+ // WHATWG "collect an HTTP quoted string" algorithm does, and it is the only
1071
+ // reading under which `note="a; filename=evil.txt\"` where the `\"` escapes
1072
+ // the quote that would have closed the value yields no filename at all. The
1073
+ // cost is that a sloppy sender's unterminated value swallows a real
1074
+ // `filename` after it; the benefit is that a hostile one cannot smuggle a
1075
+ // fake one out. `filename="report.pdf` still works, because there the
1076
+ // unterminated value IS the filename.
1077
+ const params = dispositionParams(disposition);
1078
+ // RFC 8187: charset, language, then the text, with the language ordinarily
1079
+ // empty. Any charset and any language, not only `UTF-8''` — matching the
1080
+ // empty spelling alone meant `ISO-8859-1''…` and `UTF-8'en'…` were read by
1081
+ // neither branch, and a download the platform had named came back unnamed.
1082
+ // The FIRST USABLE one of each name, not the first one. A repeated parameter
1083
+ // is malformed and the platform never sends one, but the regex this replaced
1084
+ // walked on from an occurrence it could not read — `[^";]+` cannot match an
1085
+ // empty value — and answered from the next. Stopping at the first occurrence
1086
+ // instead means `filename=""; filename=real.txt` loses a name that was there,
1087
+ // which is a regression on an already-invalid header rather than a defence:
1088
+ // every parameter in this list is one the sender genuinely wrote at the top
1089
+ // level, since a smuggled one never becomes a parameter at all.
1090
+ for (const p of params) {
1091
+ if (p.name !== 'filename*')
1092
+ continue;
1093
+ const ext = /^[^']*'[^']*'([\s\S]*)$/.exec(p.value);
1094
+ // An empty `filename*` names nothing, so keep looking — at another
1095
+ // `filename*`, and then at the plain `filename` beside it. Either beats
1096
+ // answering with the empty string.
1097
+ if (!ext?.[1])
1098
+ continue;
922
1099
  try {
923
- return decodeURIComponent(star[3]);
1100
+ return decodeURIComponent(ext[1]);
924
1101
  }
925
1102
  catch {
926
- return star[3];
1103
+ // A stray `%` in a guest filename is legal on disk and makes this throw.
1104
+ // Letting it out would turn a download whose bytes already arrived intact
1105
+ // into a failure, over the label on it.
1106
+ return ext[1];
927
1107
  }
928
1108
  }
929
- const plain = /filename="?([^";]+)"?/i.exec(disposition);
930
- return plain ? plain[1] : undefined;
1109
+ for (const p of params)
1110
+ if (p.name === 'filename' && p.value)
1111
+ return p.value;
1112
+ return undefined;
931
1113
  }
932
1114
  //# sourceMappingURL=api.js.map