mandala-computer-mcp 0.6.0 → 0.8.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 +268 -27
  2. package/dist/api.d.ts +26 -1
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +23 -0
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli.d.ts +7 -0
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +57 -1
  9. package/dist/cli.js.map +1 -1
  10. package/dist/errors.d.ts +114 -3
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/errors.js +253 -3
  13. package/dist/errors.js.map +1 -1
  14. package/dist/format.d.ts +73 -2
  15. package/dist/format.d.ts.map +1 -1
  16. package/dist/format.js +81 -3
  17. package/dist/format.js.map +1 -1
  18. package/dist/http.d.ts +32 -0
  19. package/dist/http.d.ts.map +1 -1
  20. package/dist/http.js +440 -57
  21. package/dist/http.js.map +1 -1
  22. package/dist/paths.d.ts +75 -7
  23. package/dist/paths.d.ts.map +1 -1
  24. package/dist/paths.js +88 -12
  25. package/dist/paths.js.map +1 -1
  26. package/dist/server.d.ts +1 -1
  27. package/dist/server.d.ts.map +1 -1
  28. package/dist/server.js +3 -1
  29. package/dist/server.js.map +1 -1
  30. package/dist/tool-filters.d.ts +4 -4
  31. package/dist/tool-filters.d.ts.map +1 -1
  32. package/dist/tool-filters.js +13 -1
  33. package/dist/tool-filters.js.map +1 -1
  34. package/dist/tools/account.d.ts +14 -0
  35. package/dist/tools/account.d.ts.map +1 -1
  36. package/dist/tools/account.js +118 -0
  37. package/dist/tools/account.js.map +1 -1
  38. package/dist/tools/agent.d.ts.map +1 -1
  39. package/dist/tools/agent.js +19 -25
  40. package/dist/tools/agent.js.map +1 -1
  41. package/dist/tools/chat.d.ts.map +1 -1
  42. package/dist/tools/chat.js +46 -4
  43. package/dist/tools/chat.js.map +1 -1
  44. package/dist/tools/computers.d.ts.map +1 -1
  45. package/dist/tools/computers.js +297 -51
  46. package/dist/tools/computers.js.map +1 -1
  47. package/dist/tools/guest.d.ts.map +1 -1
  48. package/dist/tools/guest.js +27 -4
  49. package/dist/tools/guest.js.map +1 -1
  50. package/dist/tools/input.d.ts +9 -0
  51. package/dist/tools/input.d.ts.map +1 -1
  52. package/dist/tools/input.js +247 -16
  53. package/dist/tools/input.js.map +1 -1
  54. package/dist/tools/operations.d.ts +49 -0
  55. package/dist/tools/operations.d.ts.map +1 -0
  56. package/dist/tools/operations.js +255 -0
  57. package/dist/tools/operations.js.map +1 -0
  58. package/dist/tools/secrets.d.ts +10 -1
  59. package/dist/tools/secrets.d.ts.map +1 -1
  60. package/dist/tools/secrets.js +63 -18
  61. package/dist/tools/secrets.js.map +1 -1
  62. package/dist/tools/snapshots.d.ts.map +1 -1
  63. package/dist/tools/snapshots.js +54 -18
  64. package/dist/tools/snapshots.js.map +1 -1
  65. package/dist/tools/ssh.d.ts.map +1 -1
  66. package/dist/tools/ssh.js +19 -2
  67. package/dist/tools/ssh.js.map +1 -1
  68. package/dist/tools/templates.js +2 -2
  69. package/dist/tools/templates.js.map +1 -1
  70. package/dist/tools/webhooks.js +1 -1
  71. package/dist/tools/webhooks.js.map +1 -1
  72. package/package.json +1 -1
@@ -1,8 +1,10 @@
1
1
  import { z } from 'zod';
2
+ import { idempotencyHeaders, idempotencyKeyFor } from '../api.js';
2
3
  import { CancelledError, ConflictError, isTransientForPoll, MoveRequiredError, NotFoundError, } from '../errors.js';
3
- import { describe, guarded, incompleteWarning, json, nothingAdmitted, refused, said, unwrapComputer, withErrorMetadata, withoutCredentials, } from '../format.js';
4
+ import { describe, guarded, incompleteWarning, json, nothingAdmitted, operationClause, operationIdOf, refused, said, secretsOnTheirWay, unwrapComputer, withErrorMetadata, withoutCredentials, } from '../format.js';
4
5
  import * as P from '../paths.js';
5
6
  import { heartbeat, POLL_MS, pollDelay, sleep } from '../poll.js';
7
+ import { buildIdempotencyKeyArg, idempotencyKeyArg, keyedFailure, restartIdempotencyKeyArg, } from './operations.js';
6
8
  import { FILES_DIR, secretBindingsSchema } from './secrets.js';
7
9
  const idArg = {
8
10
  computer_id: z
@@ -10,6 +12,52 @@ const idArg = {
10
12
  .optional()
11
13
  .describe('Which computer. Defaults to the one selected with use_computer.'),
12
14
  };
15
+ /**
16
+ * A browser proxy as the tools take it: checked for shape only. Which schemes,
17
+ * hosts and bypass entries are accepted is the platform's rule and is growing,
18
+ * so a value it refuses comes back as its own 400 sentence rather than a copy
19
+ * of the rule here that would refuse what it has since learned to accept.
20
+ * Strict, as a secret binding is: a misspelt `bypass` dropped would send the
21
+ * browsers through the proxy for every host the caller meant to exempt. The
22
+ * credentials id is checked here because its form is an id's, not a rule that
23
+ * grows, and because a setting replaced whole without it loses the credentials.
24
+ */
25
+ const browserProxySchema = z.strictObject({
26
+ server: z
27
+ .string()
28
+ .refine((v) => v.trim().length > 0, 'server must not be blank')
29
+ .describe('The proxy URL, e.g. "http://proxy.example.com:3128" or "socks5://127.0.0.1:1080". The platform says which schemes and hosts it accepts, and refuses anything else with a sentence naming why.'),
30
+ bypass: z
31
+ .array(z.string().refine((v) => v.trim().length > 0, 'a bypass entry must not be blank'))
32
+ .optional()
33
+ .describe('Hosts the browsers reach directly: "example.com", "*.example.com", an address or a range such as "192.0.2.0/24", or "<local>" for names with no dot.'),
34
+ credentials_secret_id: z
35
+ .string()
36
+ .regex(/^csec-[0-9a-f]{16}$/, 'credentials_secret_id must be a secret id: csec- and 16 hex')
37
+ .nullable()
38
+ .optional()
39
+ .describe('For a proxy that asks for a username and password: the id (csec-…, from list_secrets) of a secret whose value is "user:password". The secret must be bound to the computer as a FILE ({secret_id, file} in its secrets, at create or with set_computer_secrets), or the platform refuses the setting; a computer\'s first secrets are bound while it is stopped, and while the proxy names the secret a rebind that drops that binding is refused. Only with an http:// proxy for now. The secret\'s user:password is sent to the proxy named in server, on every request the browsers make. Leaving it out (or null) means no credentials.'),
40
+ });
41
+ const BROWSER_PROXY_ABOUT = "Sends the computer's browsers (Chromium, Chrome, Firefox) through a proxy; nothing else on it uses the proxy, so exec and a terminal go out directly. Linux only.";
42
+ /**
43
+ * An egress proxy as the tools take it, checked for shape only, as the browser
44
+ * proxy is. Strict: `bypass` copied over from a browser proxy has no meaning
45
+ * here, and a proxy that silently covered the hosts a caller meant to leave out
46
+ * is worse than a refusal naming the key.
47
+ */
48
+ const egressProxySchema = z.strictObject({
49
+ server: z
50
+ .string()
51
+ .refine((v) => v.trim().length > 0, 'server must not be blank')
52
+ .describe('The proxy URL with an explicit port: "http://host:port" (a proxy that takes CONNECT), "https://host:port" (the same, spoken to over TLS) or "socks5://host:port". Never a username or password in it — name a secret in credentials_secret_id instead. The platform says which hosts it accepts, and refuses anything else with a sentence naming why.'),
53
+ credentials_secret_id: z
54
+ .string()
55
+ .regex(/^csec-[0-9a-f]{16}$/, 'credentials_secret_id must be a secret id: csec- and 16 hex')
56
+ .nullable()
57
+ .optional()
58
+ .describe('For a proxy that asks for a username and password: the id (csec-…, from list_secrets) of a secret whose value is "user:password". It is NOT bound to the computer and the computer never receives it — do not add it to the computer\'s secrets: the computer\'s host holds the value and signs in to the proxy for it. With http:// and socks5:// the credentials cross the network in clear text, so prefer https:// when naming them. Leaving it out (or null) means no credentials.'),
59
+ });
60
+ const EGRESS_PROXY_ABOUT = "Sends ALL of the computer's outbound TCP through a proxy — exec, terminals, package managers and browsers alike — taken on its host, so nothing inside the computer can opt out. There is no bypass list. It FAILS CLOSED: when the proxy is down or refuses, or its credentials have not reached the host yet (get_computer then says its egress proxy is waiting for credentials), the connection fails and nothing is sent directly. UDP to the internet and ICMP are dropped (QUIC falls back to TCP; NTP and other UDP stop working). DNS lookups are NOT proxied; they go to the platform's resolver. Connections open through the proxy are closed when the setting changes. A host that cannot take it yet — or cannot take an https:// one, or one naming credentials — answers 409 with reason unsupported; one that cannot put it into effect now answers 503, and nothing is changed unless that error says the new setting was stored.";
13
61
  /**
14
62
  * The answer to a wait the caller ended.
15
63
  *
@@ -207,18 +255,18 @@ const finishedMove = (id, m) => {
207
255
  */
208
256
  const POWER_NEXT = {
209
257
  start: ' wait_for_computer with until="guest" is what says when the desktop is answering.',
210
- restart: ' wait_for_computer with until="guest" is what says when the desktop is back.',
258
+ restart: ' wait_for_computer with until="guest" is what says when the desktop is back — and, on a computer with secrets bound, when they have been delivered again, where the platform reports that redelivery. Where it does not, the wait can return a few seconds before they land, so a command that finds a secret unset just after a restart is worth retrying.',
211
259
  };
212
260
  const POWER_DESCRIPTIONS = {
213
261
  start: "Boot a computer, or resume a suspended one — a resume restores the saved session, same processes and windows, in about a second. A resumed guest's clock is set to the current time within a few seconds, best effort through its guest agent (one whose agent does not answer keeps the old time until NTP corrects it), and the desktop panel's clock can show the old time for up to a minute after that.",
214
262
  stop: 'Shut a computer down: the guest is asked, and given time to do it. On a suspended computer this discards the saved session, and the next start is a fresh boot. The disk is kept. `force` pulls the power instead, for a guest that will not come down on its own.',
215
263
  suspend: "Write the guest's RAM to disk and give the host its memory back. A pause, not a stop: start_computer resumes the same session. A computer that holds secrets suspends only on a host that seals saved sessions (its memory is encrypted on the way to disk); elsewhere, and while its secrets are still being delivered, it is refused with 409. After a change to its secret bindings that dropped a secret or moved one to another revision while it ran, it cannot be suspended until it restarts, since its memory may still hold the old value.",
216
- restart: 'Reset the computer, like the reset button — not a fresh boot, so a change that needs a new machine shape (a resize) needs a stop and a start instead. A suspended computer is refused with 409: start it to resume, or stop it to discard the session. A stopped computer with no secrets bound is started instead; one with secrets bound is refused with 409 while stopped (start it, which delivers them) or while its secrets are being delivered, and otherwise gets its secrets again as it comes back up. A restart issues new desktop credentials and closes open desktop connections, so a get_desktop_url link from before it stops working.',
264
+ restart: 'Reset the computer, like the reset button — not a fresh boot, so a change that needs a new machine shape (a resize) needs a stop and a start instead. A suspended computer is refused with 409: start it to resume, or stop it to discard the session. A stopped computer with no secrets bound is started instead; one with secrets bound is refused with 409 while stopped (start it, which delivers them) or while its secrets are being delivered, and otherwise gets its secrets again as it comes back up, a few seconds after it reads running: wait_for_computer with until="guest" waits for them where the platform reports that redelivery, and where it does not, a command run in those seconds can see them unset. A restart issues new desktop credentials and closes open desktop connections, so a get_desktop_url link from before it stops working.',
217
265
  };
218
266
  export const registerComputers = (server, session, opts) => {
219
267
  server.registerTool('list_templates', {
220
268
  title: 'List templates',
221
- description: 'The base images a computer can be created from — name, OS, and the default CPU, RAM and disk each one implies. For the templates the platform publishes, `name` is what a create takes as `template`; for one you published yourself, pass its `ref` (namespace/name@version) — a short name is not resolved to your own templates and falls back to the default.',
269
+ description: 'The base images a computer can be created from — name, OS, the default CPU and RAM each one implies, and its minimum disk (a smaller disk_gb is raised to it and charged at it). For the templates the platform publishes, `name` is what a create takes as `template`; for one you published yourself, pass its `ref` (namespace/name@version) — a short name is not resolved to your own templates. A short name that is none of the published templates falls back to base, except while this list is INCOMPLETE (a host did not answer): then the create is refused with a retryable 503 that changed nothing, so send it again shortly with the same idempotency_key, or pass a ref to avoid the guess.',
222
270
  inputSchema: {},
223
271
  annotations: { readOnlyHint: true },
224
272
  }, (_args, extra) => guarded(async () => {
@@ -426,15 +474,25 @@ export const registerComputers = (server, session, opts) => {
426
474
  // computers somebody else made still has to be able to bring one up, and a
427
475
  // stopped computer refuses every other tool here.
428
476
  //
429
- // Four tools around one request, and stop registered on its own below it:
430
- // stop is the only power action with a second argument to take, and folding
431
- // an optional one into the loop would leave the other three advertising a
477
+ // Four tools around one request, with start and stop registered on their
478
+ // own: each has a second argument to take (resume_only, force), and folding
479
+ // an optional one into the loop would leave the others advertising a
432
480
  // parameter their route does not read.
433
481
  const power = (action, computer_id, extra, opts = {}) => guarded(async () => {
434
482
  const id = session.resolve(computer_id);
435
- const body = await session.api
436
- .with(extra.signal)
437
- .json('POST', P.computerAction(id, action), { query: opts.query });
483
+ // Made once, before the request: the key this call is known by if its
484
+ // answer is lost (OPL-5127).
485
+ const key = idempotencyKeyFor(opts.key);
486
+ let body;
487
+ try {
488
+ body = await session.api.with(extra.signal).json('POST', P.computerAction(id, action), {
489
+ query: opts.query,
490
+ headers: idempotencyHeaders(key),
491
+ });
492
+ }
493
+ catch (err) {
494
+ return keyedFailure(err, `${action}_computer`, action, key);
495
+ }
438
496
  const c = unwrapComputer(body);
439
497
  // What the platform documents for all four is an Ack — `{ok: true}` and
440
498
  // nothing else — and that is what it sends. Formatting it as a computer
@@ -447,17 +505,42 @@ export const registerComputers = (server, session, opts) => {
447
505
  // one: the test for "is this an Ack" is the absence of an id, and a body
448
506
  // with no id is not thereby a body with no `vnc`.
449
507
  if (!c.id) {
450
- return said(`${action}: ok — ${id}.${POWER_NEXT[action] ?? ''}${opts.note ?? ''}`, withoutCredentials(c));
508
+ return said(`${action}: ok — ${id}${operationClause(body)}.${POWER_NEXT[action] ?? ''}${opts.note ?? ''}`, withoutCredentials(c));
451
509
  }
452
510
  session.noteResolution(id, c.resolution);
453
- return said(`${action}: ${describe(c)}${opts.note ?? ''}`, withoutCredentials(c));
511
+ return said(`${action}: ${describe(c)}${operationClause(body)}${opts.note ?? ''}`, withoutCredentials(c));
454
512
  });
455
- for (const action of ['start', 'suspend', 'restart']) {
513
+ server.registerTool('start_computer', {
514
+ title: 'Start a computer',
515
+ description: POWER_DESCRIPTIONS.start,
516
+ inputSchema: {
517
+ ...idArg,
518
+ resume_only: z
519
+ .boolean()
520
+ .optional()
521
+ .describe('Resume a saved session only, never boot. On a suspended computer it resumes as usual; on a stopped computer with no saved session it SUCCEEDS WITHOUT BOOTING anything, so read get_computer afterwards to see which happened.'),
522
+ idempotency_key: idempotencyKeyArg,
523
+ },
524
+ }, ({ computer_id, resume_only, idempotency_key }, extra) => power('start', computer_id, extra, {
525
+ key: idempotency_key,
526
+ // `enum: ['true']` on the platform, as stop's `force` is: omitted
527
+ // rather than sent as false.
528
+ query: { resume_only: resume_only ? 'true' : undefined },
529
+ note: resume_only
530
+ ? '\n\nresume_only: a stopped computer with no saved session is answered the same way without being booted, so this does not say it is running — get_computer or wait_for_computer says which it is.'
531
+ : undefined,
532
+ }));
533
+ for (const action of ['suspend', 'restart']) {
456
534
  server.registerTool(`${action}_computer`, {
457
535
  title: `${action[0].toUpperCase()}${action.slice(1)} a computer`,
458
536
  description: POWER_DESCRIPTIONS[action],
459
- inputSchema: { ...idArg },
460
- }, ({ computer_id }, extra) => power(action, computer_id, extra));
537
+ inputSchema: {
538
+ ...idArg,
539
+ // A restart reads running before and after, so its spent-key route
540
+ // is not "read get_computer" (see resendAfterSpentKey).
541
+ idempotency_key: action === 'restart' ? restartIdempotencyKeyArg : idempotencyKeyArg,
542
+ },
543
+ }, ({ computer_id, idempotency_key }, extra) => power(action, computer_id, extra, { key: idempotency_key }));
461
544
  }
462
545
  server.registerTool('stop_computer', {
463
546
  title: 'Stop a computer',
@@ -468,8 +551,10 @@ export const registerComputers = (server, session, opts) => {
468
551
  .boolean()
469
552
  .optional()
470
553
  .describe('Pull the power instead of asking, the way holding the button in does. Anything the guest had not written to disk is lost, so this is the second attempt and not the first: stop it politely, and reach for `force` when what comes back is a computer still running — a hung X session, a modal "unsaved changes" dialog, or a service that ignores SIGTERM will refuse the polite stop identically every time it is asked.'),
554
+ idempotency_key: idempotencyKeyArg,
471
555
  },
472
- }, ({ computer_id, force }, extra) => power('stop', computer_id, extra, {
556
+ }, ({ computer_id, force, idempotency_key }, extra) => power('stop', computer_id, extra, {
557
+ key: idempotency_key,
473
558
  // The platform's schema for this one is `enum: ['true']` — a string,
474
559
  // with no false in it — so an unforced stop omits the parameter rather
475
560
  // than sending `force=false`, the way `allow_partial` and
@@ -486,7 +571,7 @@ export const registerComputers = (server, session, opts) => {
486
571
  }));
487
572
  server.registerTool('update_computer', {
488
573
  title: 'Rename or resize a computer',
489
- description: "Change a computer's name, its size, or its idle window. The platform refuses these in combination on purpose — a resize needs the computer stopped and the other two do not, so one request cannot honour both without applying half of it. A SUSPENDED computer counts as stopped for a resize, and its saved desktop cannot survive one: the vCPU count and the memory size are part of the saved state, so it is discarded and the next start is a cold boot. Resume it and finish what is open before resizing, or say so before you do it.",
574
+ description: "Change a computer's name, its size, its idle window, its browser proxy, or its egress proxy. The platform refuses these in combination on purpose — a resize needs the computer stopped and the others do not, so one request cannot honour both without applying half of it. A SUSPENDED computer counts as stopped for a resize, and its saved desktop cannot survive one: the vCPU count and the memory size are part of the saved state, so it is discarded and the next start is a cold boot. Resume it and finish what is open before resizing, or say so before you do it.",
490
575
  inputSchema: {
491
576
  ...idArg,
492
577
  name: z.string().optional(),
@@ -506,18 +591,39 @@ export const registerComputers = (server, session, opts) => {
506
591
  .nullable()
507
592
  .optional()
508
593
  .describe("Minutes untouched before the host suspends it, at most 10080 (a week). 0 disables idle suspend and pressure eviction, up to the plan's limit on computers that never suspend (a 402 beyond it). null returns it to the host's own window. Send this on its own."),
594
+ browser_proxy: browserProxySchema
595
+ .nullable()
596
+ .optional()
597
+ .describe(`${BROWSER_PROXY_ABOUT} Replaces the setting whole; null removes it. To change the bypass of the same proxy, start from get_computer's browser_proxy and copy its credentials_secret_id to keep it: leaving it out removes the credentials, and the proxy then refuses the browsers. Do not carry credentials_secret_id to a different server: those credentials belong to the proxy they were set for; send a new server without it unless the user says those credentials are for that server. Send this on its own. A running computer has it within seconds — wait_for_computer with until="guest" before opening a browser that must use it — and a stopped or suspended one is given it as it starts. A browser already open picks it up at its next start.`),
598
+ egress_proxy: egressProxySchema
599
+ .nullable()
600
+ .optional()
601
+ .describe(`${EGRESS_PROXY_ABOUT} Replaces the setting whole; null removes it and the computer's traffic goes directly again. To change the server of the same proxy, start from the egress proxy get_computer returns and copy its credentials_secret_id to keep it: leaving it out removes the credentials, and the proxy then refuses every connection. Do not carry credentials_secret_id to a different server unless the user says those credentials are for that server. Send this ON ITS OWN — never beside name, a resize, idle_suspend_min or browser_proxy. A running computer has it when the answer arrives; a stopped or suspended one is given it before it starts. After any other 5xx that names no operation_id, send the setting again with the SAME idempotency_key (not a new one or none): the platform most likely refused it before sending it anywhere. After a 5xx that names an operation_id, or no answer, it may or may not have taken effect: call get_computer, and if it does not show the setting you sent, or its egress proxy names credentials and is still waiting for them after a few seconds, send the setting again — with a new idempotency_key, or none, after a 5xx that names an operation_id; with the key the answer named after no answer at all.`),
602
+ idempotency_key: idempotencyKeyArg,
509
603
  },
510
- }, ({ computer_id, ...fields }, extra) => guarded(async () => {
604
+ }, ({ computer_id, idempotency_key, ...fields }, extra) => guarded(async () => {
511
605
  const id = session.resolve(computer_id);
512
- // `null` is meaningful for idle_suspend_min and must survive the filter;
513
- // every other absent field is dropped so the platform leaves it alone.
606
+ // `null` is meaningful for idle_suspend_min, browser_proxy and
607
+ // egress_proxy and must survive the filter; every other absent field is
608
+ // dropped so the platform leaves it alone.
514
609
  const body = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
610
+ if (fields.egress_proxy)
611
+ body.egress_proxy = P.egressProxyBody(fields.egress_proxy);
515
612
  if (!Object.keys(body).length) {
516
- return refused('Nothing to change — give at least one of name, cpu, ram_mb, disk_gb, idle_suspend_min.');
613
+ return refused('Nothing to change — give at least one of name, cpu, ram_mb, disk_gb, idle_suspend_min, browser_proxy, egress_proxy.');
614
+ }
615
+ // The platform refuses it beside anything else; said here, before a
616
+ // request, since nothing about it depends on the computer's state.
617
+ if ('egress_proxy' in body && Object.keys(body).length > 1) {
618
+ const beside = Object.keys(body).filter((k) => k !== 'egress_proxy');
619
+ return refused(`egress_proxy must be sent on its own, not beside ${beside.join(', ')}. Send it in an update_computer call of its own. Nothing was changed.`);
517
620
  }
621
+ const key = idempotencyKeyFor(idempotency_key);
518
622
  let c;
519
623
  try {
520
- c = unwrapComputer(await session.api.with(extra.signal).json('PATCH', P.computer(id), { body }));
624
+ c = unwrapComputer(await session.api
625
+ .with(extra.signal)
626
+ .json('PATCH', P.computer(id), { body, headers: idempotencyHeaders(key) }));
521
627
  }
522
628
  catch (err) {
523
629
  // The one refusal on this route that is an offer rather than an end
@@ -527,10 +633,12 @@ export const registerComputers = (server, session, opts) => {
527
633
  // knowing about.
528
634
  if (err instanceof MoveRequiredError)
529
635
  return moveOffered(id, err);
530
- throw err;
636
+ return keyedFailure(err, 'update_computer', 'change', key);
531
637
  }
532
638
  session.noteResolution(id, c.resolution);
533
- return said(describe(c), withoutCredentials(c));
639
+ // A resize is recorded as an operation and its answer carries the id;
640
+ // a rename or a setting change carries none, and the clause is empty.
641
+ return said(`${describe(c)}${operationClause(c)}`, withoutCredentials(c));
534
642
  }));
535
643
  server.registerTool('move_computer', {
536
644
  title: 'Move a computer to a host that can run a bigger size',
@@ -567,8 +675,9 @@ export const registerComputers = (server, session, opts) => {
567
675
  .max(900)
568
676
  .default(300)
569
677
  .describe('How long to wait for the move to finish before handing back and letting you poll.'),
678
+ idempotency_key: idempotencyKeyArg,
570
679
  },
571
- }, ({ computer_id, ram_mb, cpu, disk_gb, timeout_s }, extra) => guarded(async () => {
680
+ }, ({ computer_id, ram_mb, cpu, disk_gb, timeout_s, idempotency_key }, extra) => guarded(async () => {
572
681
  const id = session.resolve(computer_id);
573
682
  const body = {
574
683
  ram_mb,
@@ -587,7 +696,21 @@ export const registerComputers = (server, session, opts) => {
587
696
  // The 202. Its body is the move as it stood the moment it was accepted,
588
697
  // and it is kept because it is the only description of this move that
589
698
  // does not depend on a later read succeeding.
590
- const started = (await api.json('POST', P.computerAction(id, 'move'), { body }));
699
+ const key = idempotencyKeyFor(idempotency_key);
700
+ let started;
701
+ try {
702
+ started = (await api.json('POST', P.computerAction(id, 'move'), {
703
+ body,
704
+ headers: idempotencyHeaders(key),
705
+ }));
706
+ }
707
+ catch (err) {
708
+ return keyedFailure(err, 'move_computer', 'move', key);
709
+ }
710
+ // The rows of GET /moves never carry the operation, so it is carried
711
+ // onto each one this wait reports.
712
+ const operation = operationIdOf(started);
713
+ const tagged = (m) => (operation ? { ...m, operation_id: operation } : m);
591
714
  // The keepalive. A disk crossing between two hosts is minutes, and a
592
715
  // tool that says nothing for minutes is one a client cancels — see
593
716
  // heartbeat, and OPL-4579 for the wait that proved it.
@@ -671,9 +794,9 @@ export const registerComputers = (server, session, opts) => {
671
794
  await sleep(POLL_MS, signal);
672
795
  continue;
673
796
  }
674
- last = mine;
797
+ last = tagged(mine);
675
798
  if (!mine.live)
676
- return finishedMove(id, mine);
799
+ return finishedMove(id, last);
677
800
  await beat(`Moving ${id} — ${mine.state}${mine.detail ? `: ${mine.detail}` : ''}`);
678
801
  await sleep(POLL_MS, signal);
679
802
  }
@@ -744,13 +867,13 @@ export const registerComputers = (server, session, opts) => {
744
867
  }));
745
868
  server.registerTool('wait_for_computer', {
746
869
  title: 'Wait for a computer to be ready',
747
- description: 'Poll until the computer is running, or until the software inside it answers. Use "guest" before exec, files or windows, and before expecting a screenshot to show a desktop rather than a boot screen. Reports progress while it waits, so a client that sends a progressToken and sets resetTimeoutOnProgress can hold the request open; a client that cannot should lower timeout_s and call again rather than watch its own default timeout cancel the wait.',
870
+ description: 'Poll until the computer is running, or until the software inside it answers. Use "guest" before exec, files or windows, and before expecting a screenshot to show a desktop rather than a boot screen; on a computer with secrets bound, "guest" also waits until they have reached the desktop, so a command run next sees them, on one whose browser proxy is being set or removed it waits until its browsers have the change, and on one whose egress proxy is waiting for its credentials it waits until they arrive, since every connection the computer opens meanwhile is closed. Reports progress while it waits, so a client that sends a progressToken and sets resetTimeoutOnProgress can hold the request open; a client that cannot should lower timeout_s and call again rather than watch its own default timeout cancel the wait.',
748
871
  inputSchema: {
749
872
  ...idArg,
750
873
  until: z
751
874
  .enum(['running', 'guest'])
752
875
  .default('guest')
753
- .describe('"running" is the hypervisor reporting the VM up. "guest" is the software inside it answering, which is what exec and a painted desktop actually need.'),
876
+ .describe('"running" is the hypervisor reporting the VM up. "guest" is the software inside it answering, which is what exec and a painted desktop actually need — and, on a computer with secrets bound, its secrets delivered, on one with a browser proxy, its browsers holding it, and on one with an egress proxy that names credentials, its host holding them.'),
754
877
  timeout_s: z.number().int().min(5).max(900).default(180),
755
878
  },
756
879
  // No readOnlyHint — this is not a pure read, so the hint would overclaim —
@@ -791,6 +914,15 @@ export const registerComputers = (server, session, opts) => {
791
914
  // report, and swallowing every transient would end the wait saying only
792
915
  // that the status was never seen.
793
916
  let blocked;
917
+ // Which proxy flag, if any, held the last status read, so the give-up
918
+ // can name it. Only the last read counts, so a wait that ran out on
919
+ // egress_proxy_pending may have met an ordinary delivery: the give-up
920
+ // invites another wait first, and names what clears a flag that
921
+ // persists (a deleted credentials secret, an update whose answer was
922
+ // lost) as the fallback. browser_proxy_pending is named too, so a
923
+ // wait held on it does not
924
+ // end saying only "last seen running".
925
+ let heldOn;
794
926
  while (!untilDeadline.aborted) {
795
927
  // The caller giving up ends the wait. The signal aborts the request
796
928
  // in flight, but nothing about an aborted request stops the next
@@ -835,6 +967,7 @@ export const registerComputers = (server, session, opts) => {
835
967
  continue;
836
968
  }
837
969
  blocked = undefined;
970
+ heldOn = undefined;
838
971
  session.noteResolution(id, c.resolution);
839
972
  last = c.status ?? 'unknown';
840
973
  // ONE beat per turn, and this is not it when a guest probe is about to
@@ -898,11 +1031,44 @@ export const registerComputers = (server, session, opts) => {
898
1031
  return refused(`${id} is suspended, and that state does not clear by itself. start_computer resumes the saved session in about a second.`, withoutCredentials(c));
899
1032
  }
900
1033
  if (last === 'stopped' && nothingAdmitted(c)) {
901
- return refused(`${id} is stopped. start_computer boots it.`, withoutCredentials(c));
1034
+ const why = typeof c.secrets_error === 'string' && c.secrets_error.trim()
1035
+ ? ` Its secrets were not delivered, so the platform stopped it: ${c.secrets_error.trim()}.`
1036
+ : '';
1037
+ return refused(`${id} is stopped.${why} start_computer boots it.`, withoutCredentials(c));
902
1038
  }
903
1039
  if (last === 'running') {
904
1040
  if (until === 'running')
905
1041
  return said(`Running: ${describe(c)}`, withoutCredentials(c));
1042
+ // A bound computer runs, and its guest answers, a few seconds
1043
+ // before its secrets land — and a command run in between sees
1044
+ // them unset. "guest" is what a caller waits on before exec, so
1045
+ // it waits for those too. A delivery that fails stops the
1046
+ // computer, which the stopped refusal below then reports. On a
1047
+ // platform without secrets_delivering, the receipt says instead.
1048
+ if (secretsOnTheirWay(c)) {
1049
+ await beat(`Waiting for ${id} — running; its secrets are still on their way.`);
1050
+ await sleep(POLL_MS, signal);
1051
+ continue;
1052
+ }
1053
+ // The same gap for a browser proxy: the guest answers before the
1054
+ // policy is on disk, and a browser opened in between goes out
1055
+ // directly. Never pending on a computer that is not running, so
1056
+ // this is the only place it can be waited on.
1057
+ if (c.browser_proxy_pending === true) {
1058
+ heldOn = 'browser';
1059
+ await beat(`Waiting for ${id} — running; its browser proxy is still being applied.`);
1060
+ await sleep(POLL_MS, signal);
1061
+ continue;
1062
+ }
1063
+ // And for an egress proxy whose credentials have not reached the
1064
+ // host: the guest answers, but every connection it opens is
1065
+ // closed until they do, so a command run next cannot reach out.
1066
+ if (c.egress_proxy_pending === true) {
1067
+ heldOn = 'egress';
1068
+ await beat(`Waiting for ${id} — running; waiting for the egress proxy's credentials (egress_proxy_pending), which usually arrive within seconds.`);
1069
+ await sleep(POLL_MS, signal);
1070
+ continue;
1071
+ }
906
1072
  // "The guest is up" is not a status the platform reports, so it is
907
1073
  // asked rather than waited for: a trivial exec either answers, or
908
1074
  // refuses with the 409 that says the agent is not up yet.
@@ -967,9 +1133,21 @@ export const registerComputers = (server, session, opts) => {
967
1133
  // which is the same shape of answer as a cancellation and not the same
968
1134
  // as success. The message still says to call again, because the state
969
1135
  // it was waiting on may yet arrive.
1136
+ //
1137
+ // Behind an egress proxy still waiting for its credentials, the
1138
+ // answer names that state. It still says to call again first: the
1139
+ // flag is read afresh on every poll, so a wait that ran out on it may
1140
+ // have met an ordinary delivery a few seconds long. Only a flag that
1141
+ // persists across waits points at something the caller has to fix,
1142
+ // and even then removing the proxy is the user's call, not a fix: it
1143
+ // sends every connection directly.
970
1144
  return refused(blocked
971
1145
  ? `Gave up after ${timeout_s}s; the platform could not be asked about ${id} for the whole wait — the last attempt said: ${blocked}. Nothing was changed — call again to keep waiting.`
972
- : `Gave up after ${timeout_s}s; ${id} was last seen ${last}. Nothing was changed — call again to keep waiting.`);
1146
+ : heldOn === 'egress'
1147
+ ? `Gave up after ${timeout_s}s; ${id} is running, but its egress proxy's credentials have not reached its host yet (egress_proxy_pending), so every connection it opens is closed until they do. Nothing was changed — call again to keep waiting. If it persists across waits, the secret its egress_proxy.credentials_secret_id names may have been deleted, or an earlier update_computer egress_proxy change may have got a 5xx or no answer: send the egress_proxy setting again with update_computer, naming a secret that exists (list_secrets). Removing the proxy with null is for the user to decide, not a fix: it sends all of the computer's traffic directly.`
1148
+ : heldOn === 'browser'
1149
+ ? `Gave up after ${timeout_s}s; ${id} is running, but its browser proxy was still being applied in the guest (browser_proxy_pending), so a browser opened now may not use it. Nothing was changed — call again to keep waiting; if it persists, send the browser_proxy setting again with update_computer.`
1150
+ : `Gave up after ${timeout_s}s; ${id} was last seen ${last}. Nothing was changed — call again to keep waiting.`);
973
1151
  }
974
1152
  finally {
975
1153
  await beat.stop();
@@ -1132,7 +1310,7 @@ export const registerComputers = (server, session, opts) => {
1132
1310
  return;
1133
1311
  server.registerTool('create_computer', {
1134
1312
  title: 'Create a computer',
1135
- description: 'Build a new cloud desktop and select it for this session. Creating and running a computer costs money on this account. Continue an image preparation refusal only as its result instructs, retaining all original create arguments, including template, and adding the returned preparation token as an argument. This token is not a create idempotency key. Stop after success; never automatically replay after a lost or ambiguous response.',
1313
+ description: "Build a new cloud desktop and select it for this session. Creating and running a computer costs money on this account. Continue an image preparation refusal only as its result instructs, retaining all original create arguments, including template, and adding the returned preparation token as an argument. This token is not a create idempotency key. Stop after success; never automatically replay after a lost or ambiguous response without the idempotency_key that response names — with it, the same call answers the first one's result instead of building a second computer.",
1136
1314
  inputSchema: {
1137
1315
  name: z.string().optional().describe('A label. The platform picks one if you do not.'),
1138
1316
  size: z
@@ -1142,7 +1320,7 @@ export const registerComputers = (server, session, opts) => {
1142
1320
  template: z
1143
1321
  .string()
1144
1322
  .optional()
1145
- .describe('From list_templates: the short name of a template the platform publishes, e.g. "base" for Linux/Xfce, or a pinned ref (namespace/name@version) — which a template you published must be named by. A short name the host lacks falls back to the default template; a ref that names nothing is refused. Defaults to base.'),
1323
+ .describe('From list_templates: the short name of a template the platform publishes, e.g. "base" for Linux/Xfce, or a pinned ref (namespace/name@version) — which a template you published must be named by. A short name that is none of the published templates falls back to base — except while list_templates is INCOMPLETE (a host did not answer), when the create is refused with a retryable 503 that changed nothing; send it again shortly with the same idempotency_key, or pass a ref to avoid the guess. A ref that names nothing is refused. Defaults to base.'),
1146
1324
  template_transfer: z
1147
1325
  .string()
1148
1326
  .refine((value) => value.trim().length > 0, 'template_transfer must not be blank')
@@ -1163,15 +1341,25 @@ export const registerComputers = (server, session, opts) => {
1163
1341
  start: z.boolean().optional().describe('Boot it immediately. True by default.'),
1164
1342
  secrets: secretBindingsSchema(false)
1165
1343
  .optional()
1166
- .describe(`Secrets from the account to deliver into the desktop session each time the computer starts, each as an environment variable (\`env\`) or as a file under ${FILES_DIR} (\`file\`). Only ids and names are sent — never a value. Secret ids come from list_secrets (create_secret stores a new one). Linux only, and only on a template whose image can receive them. A value replaced later reaches the running computer live when bound as a file, and as a variable on an image that supports it reaches new shells and exec with desktop: true. get_computer_secrets and set_computer_secrets read and change the bindings later.`),
1344
+ .describe(`Secrets from the account to deliver into the desktop session each time the computer starts, each as an environment variable (\`env\`) or as a file under ${FILES_DIR} (\`file\`). Only ids and names are sent — never a value. Secret ids come from list_secrets (create_secret stores a new one). Linux only, and only on a template whose image can receive them. A value replaced later reaches the running computer live when bound as a file, and as a variable on an image that supports it reaches new shells and every exec, plain or with desktop: true. get_computer_secrets and set_computer_secrets read and change the bindings later.`),
1345
+ browser_proxy: browserProxySchema
1346
+ .nullable()
1347
+ .optional()
1348
+ .describe(`${BROWSER_PROXY_ABOUT} A create carrying one is always a cold boot. A credentials_secret_id must name a secret bound as a file in this create's secrets. A template you published may carry a default browser proxy, which the computer inherits when this is left out; send another proxy to use that instead, or null to create the computer with none. wait_for_computer with until="guest" waits until its browsers have it; update_computer changes or removes it later.`),
1349
+ egress_proxy: egressProxySchema
1350
+ .optional()
1351
+ .describe(`${EGRESS_PROXY_ABOUT} It is in effect from the first packet the computer sends: until a proxy's credentials reach its host, connections are closed, not sent directly (wait_for_computer with until="guest" waits for them). A create carrying one is never answered from the warm pool, and a clone does not inherit it. A credentials_secret_id naming no secret, or one whose value is not user:password, is refused with 400 before anything is created. update_computer changes or removes it later.`),
1352
+ idempotency_key: buildIdempotencyKeyArg,
1167
1353
  },
1168
1354
  annotations: { destructiveHint: false, openWorldHint: true },
1169
- }, (args, extra) => guarded(async () => {
1355
+ }, ({ idempotency_key, ...args }, extra) => guarded(async () => {
1356
+ const key = idempotencyKeyFor(idempotency_key);
1170
1357
  let data;
1171
1358
  try {
1172
- data = await session.api
1173
- .with(extra.signal)
1174
- .json('POST', P.COMPUTERS, { body: P.createBody(args) });
1359
+ data = await session.api.with(extra.signal).json('POST', P.COMPUTERS, {
1360
+ body: P.createBody(args),
1361
+ headers: idempotencyHeaders(key),
1362
+ });
1175
1363
  }
1176
1364
  catch (error) {
1177
1365
  const body = error instanceof ConflictError
@@ -1213,7 +1401,7 @@ export const registerComputers = (server, session, opts) => {
1213
1401
  ],
1214
1402
  };
1215
1403
  }
1216
- throw error;
1404
+ return keyedFailure(error, 'create_computer', 'computer', key);
1217
1405
  }
1218
1406
  const c = unwrapComputer(data);
1219
1407
  // Selection and the sentence claiming it are the same decision. Bound
@@ -1230,8 +1418,8 @@ export const registerComputers = (server, session, opts) => {
1230
1418
  // with the reason on it. Saying so plainly is the difference between a
1231
1419
  // model retrying the start and a model creating a second computer.
1232
1420
  const note = c.start_error
1233
- ? `Created ${describe(c)}, but it did not start: ${c.start_error}\nThe computer exists and is selected. start_computer often works on a second attempt.`
1234
- : `Created and selected ${describe(c)}.`;
1421
+ ? `Created ${describe(c)}${operationClause(c)}, but it did not start: ${c.start_error}\nThe computer exists and is selected. start_computer often works on a second attempt.`
1422
+ : `Created and selected ${describe(c)}${operationClause(c)}.`;
1235
1423
  return said(args.template_transfer === undefined
1236
1424
  ? note
1237
1425
  : `${note} Stop retrying this create; the template_transfer token is not a create idempotency key.`, withoutCredentials(c));
@@ -1242,16 +1430,31 @@ export const registerComputers = (server, session, opts) => {
1242
1430
  inputSchema: {
1243
1431
  ...idArg,
1244
1432
  name: z.string().optional().describe('A name for the copy.'),
1433
+ idempotency_key: buildIdempotencyKeyArg,
1245
1434
  },
1246
- }, ({ computer_id, name }, extra) => guarded(async () => {
1435
+ }, ({ computer_id, name, idempotency_key }, extra) => guarded(async () => {
1247
1436
  const id = session.resolve(computer_id);
1248
- const c = unwrapComputer(await session.api.with(extra.signal).json('POST', P.computerAction(id, 'clone'), {
1249
- body: name === undefined ? {} : { name },
1250
- }));
1437
+ const key = idempotencyKeyFor(idempotency_key);
1438
+ let answer;
1439
+ try {
1440
+ answer = await session.api
1441
+ .with(extra.signal)
1442
+ .json('POST', P.computerAction(id, 'clone'), {
1443
+ body: name === undefined ? {} : { name },
1444
+ headers: idempotencyHeaders(key),
1445
+ });
1446
+ }
1447
+ catch (err) {
1448
+ return keyedFailure(err, 'clone_computer', 'copy', key);
1449
+ }
1450
+ const c = unwrapComputer(answer);
1251
1451
  if (!c.id) {
1252
1452
  return refused(`The platform accepted the clone of ${id} but sent no id back, so the copy cannot be identified. It may exist and be billable — list_computers will say. The original stays selected.`, withoutCredentials(c));
1253
1453
  }
1254
- return said(`Cloned ${id} to ${describe(c)}. The original stays selected; use_computer to switch.`, withoutCredentials(c));
1454
+ return said(`Cloned ${id} to ${describe(c)}${operationClause(c)}. The original stays selected; use_computer to switch.` +
1455
+ (c.operation_id
1456
+ ? ` wait_for_operation with ${c.operation_id} answers when its disk has been copied.`
1457
+ : ''), withoutCredentials(c));
1255
1458
  }));
1256
1459
  server.registerTool('delete_computer', {
1257
1460
  title: 'Delete a computer',
@@ -1271,9 +1474,10 @@ export const registerComputers = (server, session, opts) => {
1271
1474
  .string()
1272
1475
  .optional()
1273
1476
  .describe('The fingerprint from snapshot_holdings. The purge is refused unless it still names the same set, so a capture that finished after you looked cannot be swept up in a decision that was never about it.'),
1477
+ idempotency_key: idempotencyKeyArg,
1274
1478
  },
1275
1479
  annotations: { destructiveHint: true, idempotentHint: true },
1276
- }, ({ computer_id, delete_snapshots, expect }, extra) => guarded(async () => {
1480
+ }, ({ computer_id, delete_snapshots, expect, idempotency_key }, extra) => guarded(async () => {
1277
1481
  // The platform makes `expect` optional, for callers that cannot read the
1278
1482
  // holdings and so were never shown a set to be held to. An MCP caller
1279
1483
  // can read them — snapshot_holdings is right there — so here it is
@@ -1291,6 +1495,7 @@ export const registerComputers = (server, session, opts) => {
1291
1495
  'check that the count and size are what you meant to destroy, and pass its fingerprint as `expect`. ' +
1292
1496
  'Nothing has been deleted.');
1293
1497
  }
1498
+ const key = idempotencyKeyFor(idempotency_key);
1294
1499
  let res;
1295
1500
  try {
1296
1501
  res = await session.api
@@ -1300,6 +1505,7 @@ export const registerComputers = (server, session, opts) => {
1300
1505
  snapshots: delete_snapshots ? 'delete' : undefined,
1301
1506
  expect: delete_snapshots ? fingerprint : undefined,
1302
1507
  },
1508
+ headers: idempotencyHeaders(key),
1303
1509
  });
1304
1510
  }
1305
1511
  catch (err) {
@@ -1338,7 +1544,7 @@ export const registerComputers = (server, session, opts) => {
1338
1544
  if (absent && !signal.aborted)
1339
1545
  session.unbind(computer_id);
1340
1546
  }
1341
- throw err;
1547
+ return keyedFailure(err, 'delete_computer', 'delete', key);
1342
1548
  }
1343
1549
  session.unbind(computer_id);
1344
1550
  // Reported as a success rather than an error, and deliberately not as
@@ -1367,9 +1573,49 @@ export const registerComputers = (server, session, opts) => {
1367
1573
  const purged = res?.snapshots_deleted === undefined
1368
1574
  ? 'its snapshots'
1369
1575
  : `${res.snapshots_deleted} of its snapshot(s)`;
1576
+ // The delete's operation (kind `delete`), where the platform recorded
1577
+ // one, said like every other lifecycle tool's and kept in the
1578
+ // structured answer so a caller can read it back with get_operation.
1579
+ const op = operationIdOf(res);
1580
+ const done = `Deleted ${computer_id}${operationClause(res)}`;
1581
+ // A purge the platform could not finish in the call (OPL-5437): it
1582
+ // answers 202 with ok:false, the computer deleted, and a count of the
1583
+ // copies still queued, refused or unknown. Reported as done-and-
1584
+ // incomplete, never as a clean purge, and kept whole in the answer.
1585
+ if (delete_snapshots && res?.ok === false) {
1586
+ const purge = isPlainRecord(res.purge) ? res.purge : {};
1587
+ const counts = ['queued', 'failed', 'unknown', 'remaining']
1588
+ .filter((k) => typeof purge[k] === 'number')
1589
+ .map((k) => `${k} ${purge[k]}`)
1590
+ .join(', ');
1591
+ const why = typeof res.error === 'string' && res.error.trim() ? res.error.trim() : undefined;
1592
+ return said(`${done}, but the snapshot purge is INCOMPLETE${counts ? ` (${counts})` : ''}${why ? `: "${why}"` : ''}. ` +
1593
+ `The computer is deleted; some of its snapshot copies may still exist. Read snapshot_holdings for ${computer_id} before retrying anything.`, {
1594
+ computer_id,
1595
+ ok: false,
1596
+ ...(op ? { operation_id: op } : {}),
1597
+ ...(res.computer_deleted !== undefined
1598
+ ? { computer_deleted: res.computer_deleted }
1599
+ : {}),
1600
+ ...(res.snapshots_deleted === undefined
1601
+ ? {}
1602
+ : { snapshots_deleted: res.snapshots_deleted }),
1603
+ ...(res.purge !== undefined ? { purge: res.purge } : {}),
1604
+ ...(why ? { error: why } : {}),
1605
+ });
1606
+ }
1370
1607
  return said(delete_snapshots
1371
- ? `Deleted ${computer_id} and ${purged}.`
1372
- : `Deleted ${computer_id}. Its disk is gone; any snapshots it had remain, as orphans that can be cloned but not restored.`);
1608
+ ? `${done} and ${purged}.`
1609
+ : `${done}. Its disk is gone; any snapshots it had remain, as orphans that can be cloned but not restored.`, op
1610
+ ? {
1611
+ computer_id,
1612
+ operation_id: op,
1613
+ ...(res?.snapshots_deleted === undefined
1614
+ ? {}
1615
+ : { snapshots_deleted: res.snapshots_deleted }),
1616
+ }
1617
+ : undefined);
1373
1618
  }));
1374
1619
  };
1620
+ const isPlainRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
1375
1621
  //# sourceMappingURL=computers.js.map