mandala-computer-mcp 0.5.0 → 0.7.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 (84) hide show
  1. package/README.md +277 -20
  2. package/dist/api.d.ts +73 -2
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +271 -22
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +17 -0
  8. package/dist/cli.js.map +1 -1
  9. package/dist/errors.d.ts +183 -5
  10. package/dist/errors.d.ts.map +1 -1
  11. package/dist/errors.js +377 -10
  12. package/dist/errors.js.map +1 -1
  13. package/dist/format.d.ts +90 -0
  14. package/dist/format.d.ts.map +1 -1
  15. package/dist/format.js +102 -2
  16. package/dist/format.js.map +1 -1
  17. package/dist/http.d.ts +42 -0
  18. package/dist/http.d.ts.map +1 -1
  19. package/dist/http.js +392 -6
  20. package/dist/http.js.map +1 -1
  21. package/dist/index.d.ts +4 -3
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +3 -3
  24. package/dist/index.js.map +1 -1
  25. package/dist/paths.d.ts +74 -3
  26. package/dist/paths.d.ts.map +1 -1
  27. package/dist/paths.js +89 -10
  28. package/dist/paths.js.map +1 -1
  29. package/dist/secret-errors.d.ts +59 -0
  30. package/dist/secret-errors.d.ts.map +1 -0
  31. package/dist/secret-errors.js +199 -0
  32. package/dist/secret-errors.js.map +1 -0
  33. package/dist/secret-store.d.ts +115 -0
  34. package/dist/secret-store.d.ts.map +1 -0
  35. package/dist/secret-store.js +105 -0
  36. package/dist/secret-store.js.map +1 -0
  37. package/dist/server.d.ts +1 -1
  38. package/dist/server.d.ts.map +1 -1
  39. package/dist/server.js +3 -1
  40. package/dist/server.js.map +1 -1
  41. package/dist/session.d.ts +6 -1
  42. package/dist/session.d.ts.map +1 -1
  43. package/dist/session.js +1 -1
  44. package/dist/session.js.map +1 -1
  45. package/dist/tool-filters.d.ts +4 -4
  46. package/dist/tool-filters.d.ts.map +1 -1
  47. package/dist/tool-filters.js +22 -2
  48. package/dist/tool-filters.js.map +1 -1
  49. package/dist/tools/account.d.ts +14 -0
  50. package/dist/tools/account.d.ts.map +1 -1
  51. package/dist/tools/account.js +118 -0
  52. package/dist/tools/account.js.map +1 -1
  53. package/dist/tools/agent.d.ts.map +1 -1
  54. package/dist/tools/agent.js +7 -2
  55. package/dist/tools/agent.js.map +1 -1
  56. package/dist/tools/computers.d.ts.map +1 -1
  57. package/dist/tools/computers.js +277 -57
  58. package/dist/tools/computers.js.map +1 -1
  59. package/dist/tools/guest.d.ts.map +1 -1
  60. package/dist/tools/guest.js +140 -22
  61. package/dist/tools/guest.js.map +1 -1
  62. package/dist/tools/input.d.ts +11 -0
  63. package/dist/tools/input.d.ts.map +1 -1
  64. package/dist/tools/input.js +277 -20
  65. package/dist/tools/input.js.map +1 -1
  66. package/dist/tools/operations.d.ts +44 -0
  67. package/dist/tools/operations.d.ts.map +1 -0
  68. package/dist/tools/operations.js +232 -0
  69. package/dist/tools/operations.js.map +1 -0
  70. package/dist/tools/secrets.d.ts +42 -13
  71. package/dist/tools/secrets.d.ts.map +1 -1
  72. package/dist/tools/secrets.js +306 -15
  73. package/dist/tools/secrets.js.map +1 -1
  74. package/dist/tools/snapshots.d.ts.map +1 -1
  75. package/dist/tools/snapshots.js +44 -15
  76. package/dist/tools/snapshots.js.map +1 -1
  77. package/dist/tools/ssh.d.ts.map +1 -1
  78. package/dist/tools/ssh.js +19 -2
  79. package/dist/tools/ssh.js.map +1 -1
  80. package/dist/tools/templates.js +1 -1
  81. package/dist/tools/templates.js.map +1 -1
  82. package/dist/tools/webhooks.js +1 -1
  83. package/dist/tools/webhooks.js.map +1 -1
  84. 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
- start: 'Boot a computer, or resume a suspended one — a resume restores the saved session, same processes and windows, in about a second.',
214
- stop: 'Shut a computer down: the guest is asked, and given time to do it. Discards a saved session if there is one. The disk is kept. `force` pulls the power instead, for a guest that will not come down on its own.',
215
- 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.",
216
- restart: 'Reset the computer. Refused while a session is suspended, since it would have to guess whether you meant to resume or discard it.',
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.",
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.',
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.",
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.',
269
+ 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.',
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(),
@@ -502,21 +587,43 @@ export const registerComputers = (server, session, opts) => {
502
587
  .number()
503
588
  .int()
504
589
  .min(0)
590
+ .max(10080)
591
+ .nullable()
592
+ .optional()
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
505
595
  .nullable()
506
596
  .optional()
507
- .describe("Minutes untouched before the host suspends it. null follows the host's own window; send this on its own."),
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, 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; with the key the answer named after no answer at all.`),
602
+ idempotency_key: idempotencyKeyArg,
508
603
  },
509
- }, ({ computer_id, ...fields }, extra) => guarded(async () => {
604
+ }, ({ computer_id, idempotency_key, ...fields }, extra) => guarded(async () => {
510
605
  const id = session.resolve(computer_id);
511
- // `null` is meaningful for idle_suspend_min and must survive the filter;
512
- // 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.
513
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);
514
612
  if (!Object.keys(body).length) {
515
- 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.`);
516
620
  }
621
+ const key = idempotencyKeyFor(idempotency_key);
517
622
  let c;
518
623
  try {
519
- 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) }));
520
627
  }
521
628
  catch (err) {
522
629
  // The one refusal on this route that is an offer rather than an end
@@ -526,10 +633,12 @@ export const registerComputers = (server, session, opts) => {
526
633
  // knowing about.
527
634
  if (err instanceof MoveRequiredError)
528
635
  return moveOffered(id, err);
529
- throw err;
636
+ return keyedFailure(err, 'update_computer', 'change', key);
530
637
  }
531
638
  session.noteResolution(id, c.resolution);
532
- 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));
533
642
  }));
534
643
  server.registerTool('move_computer', {
535
644
  title: 'Move a computer to a host that can run a bigger size',
@@ -566,8 +675,9 @@ export const registerComputers = (server, session, opts) => {
566
675
  .max(900)
567
676
  .default(300)
568
677
  .describe('How long to wait for the move to finish before handing back and letting you poll.'),
678
+ idempotency_key: idempotencyKeyArg,
569
679
  },
570
- }, ({ 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 () => {
571
681
  const id = session.resolve(computer_id);
572
682
  const body = {
573
683
  ram_mb,
@@ -586,7 +696,21 @@ export const registerComputers = (server, session, opts) => {
586
696
  // The 202. Its body is the move as it stood the moment it was accepted,
587
697
  // and it is kept because it is the only description of this move that
588
698
  // does not depend on a later read succeeding.
589
- 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);
590
714
  // The keepalive. A disk crossing between two hosts is minutes, and a
591
715
  // tool that says nothing for minutes is one a client cancels — see
592
716
  // heartbeat, and OPL-4579 for the wait that proved it.
@@ -670,9 +794,9 @@ export const registerComputers = (server, session, opts) => {
670
794
  await sleep(POLL_MS, signal);
671
795
  continue;
672
796
  }
673
- last = mine;
797
+ last = tagged(mine);
674
798
  if (!mine.live)
675
- return finishedMove(id, mine);
799
+ return finishedMove(id, last);
676
800
  await beat(`Moving ${id} — ${mine.state}${mine.detail ? `: ${mine.detail}` : ''}`);
677
801
  await sleep(POLL_MS, signal);
678
802
  }
@@ -743,13 +867,13 @@ export const registerComputers = (server, session, opts) => {
743
867
  }));
744
868
  server.registerTool('wait_for_computer', {
745
869
  title: 'Wait for a computer to be ready',
746
- 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.',
747
871
  inputSchema: {
748
872
  ...idArg,
749
873
  until: z
750
874
  .enum(['running', 'guest'])
751
875
  .default('guest')
752
- .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.'),
753
877
  timeout_s: z.number().int().min(5).max(900).default(180),
754
878
  },
755
879
  // No readOnlyHint — this is not a pure read, so the hint would overclaim —
@@ -790,6 +914,15 @@ export const registerComputers = (server, session, opts) => {
790
914
  // report, and swallowing every transient would end the wait saying only
791
915
  // that the status was never seen.
792
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;
793
926
  while (!untilDeadline.aborted) {
794
927
  // The caller giving up ends the wait. The signal aborts the request
795
928
  // in flight, but nothing about an aborted request stops the next
@@ -834,6 +967,7 @@ export const registerComputers = (server, session, opts) => {
834
967
  continue;
835
968
  }
836
969
  blocked = undefined;
970
+ heldOn = undefined;
837
971
  session.noteResolution(id, c.resolution);
838
972
  last = c.status ?? 'unknown';
839
973
  // ONE beat per turn, and this is not it when a guest probe is about to
@@ -897,11 +1031,44 @@ export const registerComputers = (server, session, opts) => {
897
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));
898
1032
  }
899
1033
  if (last === 'stopped' && nothingAdmitted(c)) {
900
- 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));
901
1038
  }
902
1039
  if (last === 'running') {
903
1040
  if (until === 'running')
904
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
+ }
905
1072
  // "The guest is up" is not a status the platform reports, so it is
906
1073
  // asked rather than waited for: a trivial exec either answers, or
907
1074
  // refuses with the 409 that says the agent is not up yet.
@@ -966,9 +1133,21 @@ export const registerComputers = (server, session, opts) => {
966
1133
  // which is the same shape of answer as a cancellation and not the same
967
1134
  // as success. The message still says to call again, because the state
968
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.
969
1144
  return refused(blocked
970
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.`
971
- : `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.`);
972
1151
  }
973
1152
  finally {
974
1153
  await beat.stop();
@@ -1131,7 +1310,7 @@ export const registerComputers = (server, session, opts) => {
1131
1310
  return;
1132
1311
  server.registerTool('create_computer', {
1133
1312
  title: 'Create a computer',
1134
- 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.",
1135
1314
  inputSchema: {
1136
1315
  name: z.string().optional().describe('A label. The platform picks one if you do not.'),
1137
1316
  size: z
@@ -1141,7 +1320,7 @@ export const registerComputers = (server, session, opts) => {
1141
1320
  template: z
1142
1321
  .string()
1143
1322
  .optional()
1144
- .describe('From list_templates, e.g. "base" for Linux/Xfce. Defaults to the platform default.'),
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 the host lacks falls back to the default template; a ref that names nothing is refused. Defaults to base.'),
1145
1324
  template_transfer: z
1146
1325
  .string()
1147
1326
  .refine((value) => value.trim().length > 0, 'template_transfer must not be blank')
@@ -1158,19 +1337,29 @@ export const registerComputers = (server, session, opts) => {
1158
1337
  resolution: z
1159
1338
  .string()
1160
1339
  .optional()
1161
- .describe('WIDTHxHEIGHT or WIDTHxHEIGHTxDEPTH, 640x480 to 3840x2160, even numbers. Create-time only — the display is a QEMU property and there is no route that changes it later. Defaults to 1280x800x24.'),
1340
+ .describe('WIDTHxHEIGHT or WIDTHxHEIGHTxDEPTH, 640x480 to 3840x2160, even numbers, depth 16, 24 or 32 (24 when left out); anything else is refused. Create-time only — the display is a QEMU property and there is no route that changes it later. Defaults to 1280x800x24.'),
1162
1341
  start: z.boolean().optional().describe('Boot it immediately. True by default.'),
1163
1342
  secrets: secretBindingsSchema(false)
1164
1343
  .optional()
1165
- .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. Linux only, and only on a template whose image can receive them. get_computer_secrets and set_computer_secrets read and change them 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 exec 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,
1166
1353
  },
1167
1354
  annotations: { destructiveHint: false, openWorldHint: true },
1168
- }, (args, extra) => guarded(async () => {
1355
+ }, ({ idempotency_key, ...args }, extra) => guarded(async () => {
1356
+ const key = idempotencyKeyFor(idempotency_key);
1169
1357
  let data;
1170
1358
  try {
1171
- data = await session.api
1172
- .with(extra.signal)
1173
- .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
+ });
1174
1363
  }
1175
1364
  catch (error) {
1176
1365
  const body = error instanceof ConflictError
@@ -1212,7 +1401,7 @@ export const registerComputers = (server, session, opts) => {
1212
1401
  ],
1213
1402
  };
1214
1403
  }
1215
- throw error;
1404
+ return keyedFailure(error, 'create_computer', 'computer', key);
1216
1405
  }
1217
1406
  const c = unwrapComputer(data);
1218
1407
  // Selection and the sentence claiming it are the same decision. Bound
@@ -1229,28 +1418,43 @@ export const registerComputers = (server, session, opts) => {
1229
1418
  // with the reason on it. Saying so plainly is the difference between a
1230
1419
  // model retrying the start and a model creating a second computer.
1231
1420
  const note = c.start_error
1232
- ? `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.`
1233
- : `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)}.`;
1234
1423
  return said(args.template_transfer === undefined
1235
1424
  ? note
1236
1425
  : `${note} Stop retrying this create; the template_transfer token is not a create idempotency key.`, withoutCredentials(c));
1237
1426
  }));
1238
1427
  server.registerTool('clone_computer', {
1239
1428
  title: 'Clone a computer',
1240
- description: 'Copy a computer to a new one — the fork half of snapshot-and-fork. The copy inherits the resolution, because its disk carries a desktop laid out at that size.',
1429
+ description: 'Copy a computer\'s current disk to a new computer — the fork half of snapshot-and-fork. The source is untouched and must be stopped or suspended: a running source is refused with 409. The answer arrives as soon as the copy exists, reading status "building" while its disk is copied — wait for that to finish before starting it. The copy keeps the source\'s size and resolution (its disk carries a desktop laid out at that size), lands stopped, and has no secrets bound.',
1241
1430
  inputSchema: {
1242
1431
  ...idArg,
1243
1432
  name: z.string().optional().describe('A name for the copy.'),
1433
+ idempotency_key: buildIdempotencyKeyArg,
1244
1434
  },
1245
- }, ({ computer_id, name }, extra) => guarded(async () => {
1435
+ }, ({ computer_id, name, idempotency_key }, extra) => guarded(async () => {
1246
1436
  const id = session.resolve(computer_id);
1247
- const c = unwrapComputer(await session.api.with(extra.signal).json('POST', P.computerAction(id, 'clone'), {
1248
- body: name === undefined ? {} : { name },
1249
- }));
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);
1250
1451
  if (!c.id) {
1251
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));
1252
1453
  }
1253
- 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));
1254
1458
  }));
1255
1459
  server.registerTool('delete_computer', {
1256
1460
  title: 'Delete a computer',
@@ -1270,9 +1474,10 @@ export const registerComputers = (server, session, opts) => {
1270
1474
  .string()
1271
1475
  .optional()
1272
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,
1273
1478
  },
1274
1479
  annotations: { destructiveHint: true, idempotentHint: true },
1275
- }, ({ computer_id, delete_snapshots, expect }, extra) => guarded(async () => {
1480
+ }, ({ computer_id, delete_snapshots, expect, idempotency_key }, extra) => guarded(async () => {
1276
1481
  // The platform makes `expect` optional, for callers that cannot read the
1277
1482
  // holdings and so were never shown a set to be held to. An MCP caller
1278
1483
  // can read them — snapshot_holdings is right there — so here it is
@@ -1290,6 +1495,7 @@ export const registerComputers = (server, session, opts) => {
1290
1495
  'check that the count and size are what you meant to destroy, and pass its fingerprint as `expect`. ' +
1291
1496
  'Nothing has been deleted.');
1292
1497
  }
1498
+ const key = idempotencyKeyFor(idempotency_key);
1293
1499
  let res;
1294
1500
  try {
1295
1501
  res = await session.api
@@ -1299,6 +1505,7 @@ export const registerComputers = (server, session, opts) => {
1299
1505
  snapshots: delete_snapshots ? 'delete' : undefined,
1300
1506
  expect: delete_snapshots ? fingerprint : undefined,
1301
1507
  },
1508
+ headers: idempotencyHeaders(key),
1302
1509
  });
1303
1510
  }
1304
1511
  catch (err) {
@@ -1337,7 +1544,7 @@ export const registerComputers = (server, session, opts) => {
1337
1544
  if (absent && !signal.aborted)
1338
1545
  session.unbind(computer_id);
1339
1546
  }
1340
- throw err;
1547
+ return keyedFailure(err, 'delete_computer', 'delete', key);
1341
1548
  }
1342
1549
  session.unbind(computer_id);
1343
1550
  // Reported as a success rather than an error, and deliberately not as
@@ -1366,9 +1573,22 @@ export const registerComputers = (server, session, opts) => {
1366
1573
  const purged = res?.snapshots_deleted === undefined
1367
1574
  ? 'its snapshots'
1368
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)}`;
1369
1581
  return said(delete_snapshots
1370
- ? `Deleted ${computer_id} and ${purged}.`
1371
- : `Deleted ${computer_id}. Its disk is gone; any snapshots it had remain, as orphans that can be cloned but not restored.`);
1582
+ ? `${done} and ${purged}.`
1583
+ : `${done}. Its disk is gone; any snapshots it had remain, as orphans that can be cloned but not restored.`, op
1584
+ ? {
1585
+ computer_id,
1586
+ operation_id: op,
1587
+ ...(res?.snapshots_deleted === undefined
1588
+ ? {}
1589
+ : { snapshots_deleted: res.snapshots_deleted }),
1590
+ }
1591
+ : undefined);
1372
1592
  }));
1373
1593
  };
1374
1594
  //# sourceMappingURL=computers.js.map