mandala-computer-mcp 0.3.0 → 0.5.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 (130) hide show
  1. package/README.md +462 -37
  2. package/dist/api.d.ts +12 -0
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +262 -42
  5. package/dist/api.js.map +1 -1
  6. package/dist/artifacts.d.ts +63 -0
  7. package/dist/artifacts.d.ts.map +1 -0
  8. package/dist/artifacts.js +81 -0
  9. package/dist/artifacts.js.map +1 -0
  10. package/dist/cli.d.ts +4 -0
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +35 -21
  13. package/dist/cli.js.map +1 -1
  14. package/dist/credentials.d.ts +33 -0
  15. package/dist/credentials.d.ts.map +1 -0
  16. package/dist/credentials.js +395 -0
  17. package/dist/credentials.js.map +1 -0
  18. package/dist/errors.d.ts +53 -10
  19. package/dist/errors.d.ts.map +1 -1
  20. package/dist/errors.js +122 -35
  21. package/dist/errors.js.map +1 -1
  22. package/dist/events.d.ts +11 -2
  23. package/dist/events.d.ts.map +1 -1
  24. package/dist/events.js +213 -95
  25. package/dist/events.js.map +1 -1
  26. package/dist/executions.d.ts +43 -0
  27. package/dist/executions.d.ts.map +1 -0
  28. package/dist/executions.js +162 -0
  29. package/dist/executions.js.map +1 -0
  30. package/dist/format.d.ts +11 -11
  31. package/dist/format.d.ts.map +1 -1
  32. package/dist/format.js +159 -16
  33. package/dist/format.js.map +1 -1
  34. package/dist/http.d.ts.map +1 -1
  35. package/dist/http.js +4 -0
  36. package/dist/http.js.map +1 -1
  37. package/dist/index.d.ts +2 -2
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/limits.d.ts +17 -0
  42. package/dist/limits.d.ts.map +1 -0
  43. package/dist/limits.js +17 -0
  44. package/dist/limits.js.map +1 -0
  45. package/dist/paths.d.ts +42 -2
  46. package/dist/paths.d.ts.map +1 -1
  47. package/dist/paths.js +54 -3
  48. package/dist/paths.js.map +1 -1
  49. package/dist/poll.d.ts +5 -1
  50. package/dist/poll.d.ts.map +1 -1
  51. package/dist/poll.js +120 -16
  52. package/dist/poll.js.map +1 -1
  53. package/dist/results.d.ts +97 -0
  54. package/dist/results.d.ts.map +1 -0
  55. package/dist/results.js +263 -0
  56. package/dist/results.js.map +1 -0
  57. package/dist/server.d.ts +3 -2
  58. package/dist/server.d.ts.map +1 -1
  59. package/dist/server.js +61 -9
  60. package/dist/server.js.map +1 -1
  61. package/dist/stdio.d.ts +5 -1
  62. package/dist/stdio.d.ts.map +1 -1
  63. package/dist/stdio.js +10 -4
  64. package/dist/stdio.js.map +1 -1
  65. package/dist/tool-filters.d.ts +38 -0
  66. package/dist/tool-filters.d.ts.map +1 -0
  67. package/dist/tool-filters.js +129 -0
  68. package/dist/tool-filters.js.map +1 -0
  69. package/dist/tools/account.d.ts +3 -0
  70. package/dist/tools/account.d.ts.map +1 -0
  71. package/dist/tools/account.js +122 -0
  72. package/dist/tools/account.js.map +1 -0
  73. package/dist/tools/activities.d.ts +3 -0
  74. package/dist/tools/activities.d.ts.map +1 -0
  75. package/dist/tools/activities.js +140 -0
  76. package/dist/tools/activities.js.map +1 -0
  77. package/dist/tools/agent.d.ts.map +1 -1
  78. package/dist/tools/agent.js +72 -5
  79. package/dist/tools/agent.js.map +1 -1
  80. package/dist/tools/artifacts.d.ts +3 -0
  81. package/dist/tools/artifacts.d.ts.map +1 -0
  82. package/dist/tools/artifacts.js +97 -0
  83. package/dist/tools/artifacts.js.map +1 -0
  84. package/dist/tools/chat.d.ts +6 -0
  85. package/dist/tools/chat.d.ts.map +1 -0
  86. package/dist/tools/chat.js +192 -0
  87. package/dist/tools/chat.js.map +1 -0
  88. package/dist/tools/computers.d.ts.map +1 -1
  89. package/dist/tools/computers.js +300 -247
  90. package/dist/tools/computers.js.map +1 -1
  91. package/dist/tools/directory.d.ts +13 -0
  92. package/dist/tools/directory.d.ts.map +1 -0
  93. package/dist/tools/directory.js +88 -0
  94. package/dist/tools/directory.js.map +1 -0
  95. package/dist/tools/events.d.ts.map +1 -1
  96. package/dist/tools/events.js +66 -22
  97. package/dist/tools/events.js.map +1 -1
  98. package/dist/tools/executions.d.ts +3 -0
  99. package/dist/tools/executions.d.ts.map +1 -0
  100. package/dist/tools/executions.js +87 -0
  101. package/dist/tools/executions.js.map +1 -0
  102. package/dist/tools/guest.d.ts.map +1 -1
  103. package/dist/tools/guest.js +57 -17
  104. package/dist/tools/guest.js.map +1 -1
  105. package/dist/tools/input.d.ts.map +1 -1
  106. package/dist/tools/input.js +63 -5
  107. package/dist/tools/input.js.map +1 -1
  108. package/dist/tools/results.d.ts +30 -0
  109. package/dist/tools/results.d.ts.map +1 -0
  110. package/dist/tools/results.js +106 -0
  111. package/dist/tools/results.js.map +1 -0
  112. package/dist/tools/secrets.d.ts +58 -0
  113. package/dist/tools/secrets.d.ts.map +1 -0
  114. package/dist/tools/secrets.js +204 -0
  115. package/dist/tools/secrets.js.map +1 -0
  116. package/dist/tools/signals.d.ts +3 -0
  117. package/dist/tools/signals.d.ts.map +1 -0
  118. package/dist/tools/signals.js +116 -0
  119. package/dist/tools/signals.js.map +1 -0
  120. package/dist/tools/snapshots.d.ts.map +1 -1
  121. package/dist/tools/snapshots.js +213 -167
  122. package/dist/tools/snapshots.js.map +1 -1
  123. package/dist/tools/ssh.d.ts +3 -0
  124. package/dist/tools/ssh.d.ts.map +1 -0
  125. package/dist/tools/ssh.js +186 -0
  126. package/dist/tools/ssh.js.map +1 -0
  127. package/dist/tools/templates.d.ts.map +1 -1
  128. package/dist/tools/templates.js +9 -13
  129. package/dist/tools/templates.js.map +1 -1
  130. package/package.json +1 -1
@@ -1,8 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  import { CancelledError, ConflictError, isTransientForPoll, MoveRequiredError, NotFoundError, } from '../errors.js';
3
- import { describe, guarded, incompleteWarning, json, nothingAdmitted, refused, said, unwrapComputer, withoutCredentials, } from '../format.js';
3
+ import { describe, guarded, incompleteWarning, json, nothingAdmitted, refused, said, unwrapComputer, withErrorMetadata, withoutCredentials, } from '../format.js';
4
4
  import * as P from '../paths.js';
5
5
  import { heartbeat, POLL_MS, pollDelay, sleep } from '../poll.js';
6
+ import { FILES_DIR, secretBindingsSchema } from './secrets.js';
6
7
  const idArg = {
7
8
  computer_id: z
8
9
  .string()
@@ -46,7 +47,11 @@ const movesOf = (body) => {
46
47
  // TypeError for a confident wrong answer, which is the worse of the two.
47
48
  // An unreadable ENVELOPE is still `undefined`: a different fact, a different
48
49
  // answer, and the one the callers already handle.
49
- const moves = list.filter((row) => row !== null && typeof row === 'object' && !Array.isArray(row));
50
+ const moves = list.filter((row) => row !== null &&
51
+ typeof row === 'object' &&
52
+ !Array.isArray(row) &&
53
+ typeof row.computer_id === 'string' &&
54
+ Boolean(row.computer_id.trim()));
50
55
  return { moves, dropped: list.length - moves.length };
51
56
  };
52
57
  /**
@@ -83,8 +88,26 @@ const moveOffered = (id, err) => refused(err.movePossible
83
88
  const moveShape = (m) => [m.cpu && `${m.cpu} vCPU`, m.ram_mb && `${m.ram_mb} MB RAM`, m.disk_gb && `${m.disk_gb} GB disk`]
84
89
  .filter(Boolean)
85
90
  .join(' · ') || 'no change';
86
- /** One row of list_moves. */
87
- const moveLine = (m) => `${m.computer_id}: ${m.state}${m.live ? ' (running)' : ''} — ${moveShape(m)}${m.detail ? ` — ${m.detail}` : ''}`;
91
+ /**
92
+ * One row of list_moves, and the three things `live` can say rather than two.
93
+ *
94
+ * `live` is the flag this tool's own description tells a model to poll on, so
95
+ * the one answer it must never give is a confident "not running" about a row
96
+ * that did not say. A truthy test gave exactly that: a row whose flag was absent
97
+ * or was not a boolean read as finished, and a caller polling for the move to end
98
+ * stops there — while a disk is still being copied between two hosts, and while
99
+ * the platform goes on refusing the next move on this account because this one
100
+ * has not finished. The watch loop already treats an unreadable flag as a poll it
101
+ * could not get an answer to; this is the same fact, said in a listing.
102
+ */
103
+ const moveLine = (m) => {
104
+ const liveness = m.live === true
105
+ ? ' (running)'
106
+ : m.live === false
107
+ ? ''
108
+ : ' (LIVENESS UNKNOWN — this row did not say whether the move is still running, so do not read it as finished)';
109
+ return `${m.computer_id}: ${m.state}${liveness} — ${moveShape(m)}${m.detail ? ` — ${m.detail}` : ''}`;
110
+ };
88
111
  /**
89
112
  * The sentence in front of a usage report, and the reason this tool does not
90
113
  * simply hand back the JSON the way list_sizes does.
@@ -568,89 +591,100 @@ export const registerComputers = (server, session, opts) => {
568
591
  // tool that says nothing for minutes is one a client cancels — see
569
592
  // heartbeat, and OPL-4579 for the wait that proved it.
570
593
  const beat = heartbeat(extra, server.server);
571
- await beat(`Moving ${id} — the platform has accepted it.`);
572
- let last = started;
573
- let blocked;
574
- while (!untilDeadline.aborted) {
575
- if (extra.signal?.aborted) {
576
- return refused(`Cancelled while waiting for ${id} to move. THE MOVE IS STILL RUNNING — nothing was stopped, ` +
577
- `because a disk crossing between two hosts cannot be called back. list_moves says where it ` +
578
- `got to.`, last);
579
- }
580
- let table;
581
- let raw;
582
- try {
583
- raw = await api.json('GET', P.MOVES);
584
- table = movesOf(raw);
585
- }
586
- catch (err) {
587
- if (extra.signal?.aborted)
594
+ try {
595
+ await beat(`Moving ${id} — the platform has accepted it.`);
596
+ let last = started;
597
+ let blocked;
598
+ while (!untilDeadline.aborted) {
599
+ if (extra.signal?.aborted) {
600
+ return refused(`Cancelled while waiting for ${id} to move. THE MOVE IS STILL RUNNING — nothing was stopped, ` +
601
+ `because a disk crossing between two hosts cannot be called back. list_moves says where it ` +
602
+ `got to.`, last);
603
+ }
604
+ let table;
605
+ let raw;
606
+ try {
607
+ raw = await api.json('GET', P.MOVES);
608
+ table = movesOf(raw);
609
+ }
610
+ catch (err) {
611
+ if (extra.signal?.aborted)
612
+ continue;
613
+ if (err instanceof CancelledError) {
614
+ if (untilDeadline.aborted)
615
+ break;
616
+ blocked = err.message;
617
+ await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
618
+ await sleep(POLL_MS, signal);
619
+ continue;
620
+ }
621
+ // The poll reads the control plane's own table, so the statuses
622
+ // worth riding out are the ones that mean "ask again" — exactly
623
+ // wait_for_computer's list. Anything else is a real failure, and
624
+ // the move is still running behind it, which a thrown error's
625
+ // handler has no way to say. So it is said here.
626
+ if (!isTransientForPoll(err)) {
627
+ return refused(`${err instanceof Error ? err.message : String(err)}\n\nTHE MOVE IS STILL RUNNING — this ` +
628
+ `was the poll failing, not the move. list_moves says where it got to.`, last);
629
+ }
630
+ blocked = err instanceof Error ? err.message : String(err);
631
+ await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
632
+ await sleep(pollDelay(err), signal);
588
633
  continue;
589
- if (err instanceof CancelledError) {
590
- if (untilDeadline.aborted)
591
- break;
592
- blocked = err.message;
634
+ }
635
+ // A table that is not a list is the platform failing to answer, not
636
+ // an answer that the move is gone. It rides out the same way a poll
637
+ // that threw does, so the deadline's sentence says the platform could
638
+ // not be asked rather than claiming a deletion nothing established.
639
+ if (!table) {
640
+ blocked = `GET /moves answered with ${shapeOf(raw?.moves)}, not a list of moves`;
593
641
  await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
594
642
  await sleep(POLL_MS, signal);
595
643
  continue;
596
644
  }
597
- // The poll reads the control plane's own table, so the statuses
598
- // worth riding out are the ones that mean "ask again" — exactly
599
- // wait_for_computer's list. Anything else is a real failure, and
600
- // the move is still running behind it, which a thrown error's
601
- // handler has no way to say. So it is said here.
602
- if (!isTransientForPoll(err)) {
603
- return refused(`${err instanceof Error ? err.message : String(err)}\n\nTHE MOVE IS STILL RUNNING — this ` +
604
- `was the poll failing, not the move. list_moves says where it got to.`, last);
645
+ blocked = undefined;
646
+ const mine = table.moves.find((m) => m.computer_id === id);
647
+ // A move that is no longer listed is one the platform reaped, and it
648
+ // reaps for one reason: the computer was deleted. Not a state to keep
649
+ // polling for.
650
+ //
651
+ // Unless a row could not be READ, in which case absence is not
652
+ // established: the move may be sitting in the row this poll had to
653
+ // drop. That is a poll that could not be answered rather than an
654
+ // answer, so it rides out exactly as a transient failure does, and
655
+ // the deadline's sentence says the platform could not be asked
656
+ // instead of claiming a deletion nothing showed.
657
+ if (!mine && table.dropped) {
658
+ blocked = `GET /moves answered with ${table.dropped} unreadable row(s), so this move may be among them`;
659
+ await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
660
+ await sleep(POLL_MS, signal);
661
+ continue;
605
662
  }
606
- blocked = err instanceof Error ? err.message : String(err);
607
- await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
608
- await sleep(pollDelay(err), signal);
609
- continue;
610
- }
611
- // A table that is not a list is the platform failing to answer, not
612
- // an answer that the move is gone. It rides out the same way a poll
613
- // that threw does, so the deadline's sentence says the platform could
614
- // not be asked rather than claiming a deletion nothing established.
615
- if (!table) {
616
- blocked = `GET /moves answered with ${shapeOf(raw?.moves)}, not a list of moves`;
617
- await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
618
- await sleep(POLL_MS, signal);
619
- continue;
620
- }
621
- blocked = undefined;
622
- const mine = table.moves.find((m) => m.computer_id === id);
623
- // A move that is no longer listed is one the platform reaped, and it
624
- // reaps for one reason: the computer was deleted. Not a state to keep
625
- // polling for.
626
- //
627
- // Unless a row could not be READ, in which case absence is not
628
- // established: the move may be sitting in the row this poll had to
629
- // drop. That is a poll that could not be answered rather than an
630
- // answer, so it rides out exactly as a transient failure does, and
631
- // the deadline's sentence says the platform could not be asked
632
- // instead of claiming a deletion nothing showed.
633
- if (!mine && table.dropped) {
634
- blocked = `GET /moves answered with ${table.dropped} unreadable row(s), so this move may be among them`;
635
- await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
663
+ if (!mine) {
664
+ return refused(`The move of ${id} is no longer listed. That happens when the computer is deleted — check ` +
665
+ `list_computers.`, last);
666
+ }
667
+ if (typeof mine.live !== 'boolean') {
668
+ blocked = `GET /moves answered with an unreadable live flag for ${id}`;
669
+ await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
670
+ await sleep(POLL_MS, signal);
671
+ continue;
672
+ }
673
+ last = mine;
674
+ if (!mine.live)
675
+ return finishedMove(id, mine);
676
+ await beat(`Moving ${id} — ${mine.state}${mine.detail ? `: ${mine.detail}` : ''}`);
636
677
  await sleep(POLL_MS, signal);
637
- continue;
638
678
  }
639
- if (!mine) {
640
- return refused(`The move of ${id} is no longer listed. That happens when the computer is deleted — check ` +
641
- `list_computers.`, last);
642
- }
643
- last = mine;
644
- if (!mine.live)
645
- return finishedMove(id, mine);
646
- await beat(`Moving ${id} — ${mine.state}${mine.detail ? `: ${mine.detail}` : ''}`);
647
- await sleep(POLL_MS, signal);
679
+ return refused(blocked
680
+ ? `Gave up watching after ${timeout_s}s; the platform could not be asked — the last attempt said: ` +
681
+ `${blocked}. THE MOVE IS STILL RUNNING. list_moves says where it got to.`
682
+ : `Still moving after ${timeout_s}s, which a large disk takes. THE MOVE IS STILL RUNNING and ` +
683
+ `nothing was changed by giving up on the wait. list_moves says where it got to.`, last);
684
+ }
685
+ finally {
686
+ await beat.stop();
648
687
  }
649
- return refused(blocked
650
- ? `Gave up watching after ${timeout_s}s; the platform could not be asked — the last attempt said: ` +
651
- `${blocked}. THE MOVE IS STILL RUNNING. list_moves says where it got to.`
652
- : `Still moving after ${timeout_s}s, which a large disk takes. THE MOVE IS STILL RUNNING and ` +
653
- `nothing was changed by giving up on the wait. list_moves says where it got to.`, last);
654
688
  }));
655
689
  server.registerTool('list_moves', {
656
690
  title: 'List moves in progress and their outcomes',
@@ -745,191 +779,200 @@ export const registerComputers = (server, session, opts) => {
745
779
  : untilDeadline;
746
780
  const api = session.api.with(signal);
747
781
  const beat = heartbeat(extra, server.server);
748
- let last = 'unknown';
749
- // Kept so the give-up message can name it. A hypervisor that was
750
- // unreachable for the whole window is the single most useful thing to
751
- // report, and swallowing every transient would end the wait saying only
752
- // that the status was never seen.
753
- let blocked;
754
- while (!untilDeadline.aborted) {
755
- // The caller giving up ends the wait. The signal aborts the request
756
- // in flight, but nothing about an aborted request stops the next
757
- // iteration from starting one — so a cancelled call would go on
758
- // polling the platform for the rest of its timeout_s, up to fifteen
759
- // minutes of traffic on behalf of nobody.
760
- if (extra.signal?.aborted)
761
- return cancelled(id, last);
762
- // The status read is exactly as transient-prone as the guest probe
763
- // below it — a hypervisor that cannot be reached answers 503, which
764
- // is the ordinary weather of a machine still coming up. Letting that
765
- // out would abort the one tool whose entire job is to keep asking.
766
- let c;
767
- try {
768
- c = unwrapComputer(await api.json('GET', P.computer(id)));
769
- }
770
- catch (err) {
771
- // The caller's own signal is checked first, and by identity rather
772
- // than by reading the error: the request is now bound to two
773
- // deadlines, and only one of them means anybody stopped caring.
782
+ try {
783
+ let last = 'unknown';
784
+ // Open the progress channel before the first status read. That read
785
+ // has the same network and response deadlines as every later poll,
786
+ // so it can be the whole wait rather than a quick prelude to it.
787
+ await beat(`Waiting for ${id} — asking the platform for its status.`);
788
+ // Kept so the give-up message can name it. A hypervisor that was
789
+ // unreachable for the whole window is the single most useful thing to
790
+ // report, and swallowing every transient would end the wait saying only
791
+ // that the status was never seen.
792
+ let blocked;
793
+ while (!untilDeadline.aborted) {
794
+ // The caller giving up ends the wait. The signal aborts the request
795
+ // in flight, but nothing about an aborted request stops the next
796
+ // iteration from starting one — so a cancelled call would go on
797
+ // polling the platform for the rest of its timeout_s, up to fifteen
798
+ // minutes of traffic on behalf of nobody.
774
799
  if (extra.signal?.aborted)
775
800
  return cancelled(id, last);
776
- // A body stream can also fail without either signal firing (an
777
- // undici idle timeout is an AbortError). That is a transport
778
- // failure, not a cancellation, and is retried below as transient.
779
- // Only the deadline signal proves the wait's own timer arrived.
780
- if (err instanceof CancelledError) {
781
- if (untilDeadline.aborted) {
782
- blocked = `the status read was still in flight when the ${timeout_s}s deadline arrived`;
783
- break;
784
- }
785
- blocked = err.message;
786
- await beat(`Waiting for ${id} — the platform could not be asked: ${blocked}`);
787
- await sleep(POLL_MS, signal);
788
- continue;
789
- }
790
- if (!isTransientForPoll(err))
791
- throw err;
792
- blocked = err instanceof Error ? err.message : String(err);
793
- await beat(`Waiting for ${id} — the platform could not be asked: ${blocked}`);
794
- await sleep(pollDelay(err), signal);
795
- continue;
796
- }
797
- blocked = undefined;
798
- session.noteResolution(id, c.resolution);
799
- last = c.status ?? 'unknown';
800
- // ONE beat per turn, and this is not it when a guest probe is about to
801
- // run. Beating here and again in the probe's failure branch sent two
802
- // notifications per poll — and because the two lines DIFFER, each read
803
- // as news to the throttle and neither was ever held, so the steady
804
- // state of the commonest long wait was twice the per-poll rate the
805
- // interval exists to avoid (/code-review).
806
- //
807
- // Fixed by making the two say the SAME thing rather than by silencing
808
- // this one, which was the first attempt and dropped the wrong half of
809
- // the pair (/code-review again). The probe below is the longest call
810
- // in the loop — an undici header timeout or a proxy 524 can hold it
811
- // for minutes — so the beat that must survive is the one IN FRONT of
812
- // it. The 409 branch repeats this line and the throttle holds it; a
813
- // probe that fails some other way says so, which is news and goes out.
814
- await beat(until === 'guest' && last === 'running'
815
- ? `Waiting for ${id} — running; asking the guest.`
816
- : `Waiting for ${id} — ${last}.`);
817
- if (last === 'build-failed') {
818
- // `refused`, for the reason `cancelled` is: the wait never reached
819
- // what it was told to wait for, and this one never will. A caller
820
- // reading `isError` to decide whether to go on would otherwise see
821
- // a build that failed and a guest that answered as the same result.
822
- //
823
- // `build.source` is what the machine was built *from*, not why the
824
- // build failed — printed bare after "Build failed:" it reads as the
825
- // reason and names an image instead of a cause. `start_error` is
826
- // the field that carries a diagnostic, so prefer it and label the
827
- // source as the source when that is all there is.
828
- const why = c.start_error
829
- ? `: ${c.start_error}`
830
- : c.build?.source
831
- ? ` (built from ${c.build.source}) — the platform gave no reason`
832
- : ' — the platform gave no reason';
833
- return refused(`Build failed${why}. This does not resolve on its own.`, withoutCredentials(c));
834
- }
835
- // Its files were partly removed and its disk is gone: the platform
836
- // refuses to start or use it, and only deleting it again clears it.
837
- // Waiting spent the whole budget and then reported "last seen
838
- // half-removed", which is the state the caller passed in (Codex
839
- // review). Not qualified by the pool: nothing can be admitted for a
840
- // machine with no disk.
841
- if (last === 'half-removed') {
842
- return refused(`${id} is half-removed: its files were partly removed, it cannot be started or used again, and delete_computer is what clears it.`, withoutCredentials(c));
843
- }
844
- // Neither of the next two resolves on its own, so spinning on either
845
- // burns the whole timeout waiting for something nobody is going to do.
846
- //
847
- // Unless somebody is. `status` is read from the guest process, so a
848
- // start that has been ADMITTED reads as `stopped` while it boots and
849
- // as `suspended` while it resumes — the session record is spent only
850
- // on the way out of a start that worked. Refusing there tells a model
851
- // to call start_computer on a computer that is already starting, and
852
- // the obvious next thing it does is start it a second time.
853
- //
854
- // nothingAdmitted is the platform's own word for idle, and it is
855
- // absent-aware: a host that did not answer has not said nothing is
856
- // coming, so the wait goes on rather than refusing (OPL-4631).
857
- if (last === 'suspended' && nothingAdmitted(c)) {
858
- 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));
859
- }
860
- if (last === 'stopped' && nothingAdmitted(c)) {
861
- return refused(`${id} is stopped. start_computer boots it.`, withoutCredentials(c));
862
- }
863
- if (last === 'running') {
864
- if (until === 'running')
865
- return said(`Running: ${describe(c)}`, withoutCredentials(c));
866
- // "The guest is up" is not a status the platform reports, so it is
867
- // asked rather than waited for: a trivial exec either answers, or
868
- // refuses with the 409 that says the agent is not up yet.
801
+ // The status read is exactly as transient-prone as the guest probe
802
+ // below it — a hypervisor that cannot be reached answers 503, which
803
+ // is the ordinary weather of a machine still coming up. Letting that
804
+ // out would abort the one tool whose entire job is to keep asking.
805
+ let c;
869
806
  try {
870
- await api.send('POST', P.computerAction(id, 'exec'), {
871
- body: P.execBody({ command: 'true', timeout_s: 5 }),
872
- });
873
- return said(`Guest is answering: ${describe(c)}`, withoutCredentials(c));
807
+ c = unwrapComputer(await api.json('GET', P.computer(id)));
874
808
  }
875
809
  catch (err) {
876
- // The same two deadlines as the status read above, and for the
877
- // same reason: this catch used to judge the error alone, so a
878
- // cancellation during the guest probe left the wait throwing what
879
- // read as a platform outage instead of saying the caller had
880
- // hung up. Half the loop knew to check the signal and half did
881
- // not, which is the worse of the two ways to be inconsistent.
810
+ // The caller's own signal is checked first, and by identity rather
811
+ // than by reading the error: the request is now bound to two
812
+ // deadlines, and only one of them means anybody stopped caring.
882
813
  if (extra.signal?.aborted)
883
814
  return cancelled(id, last);
815
+ // A body stream can also fail without either signal firing (an
816
+ // undici idle timeout is an AbortError). That is a transport
817
+ // failure, not a cancellation, and is retried below as transient.
818
+ // Only the deadline signal proves the wait's own timer arrived.
884
819
  if (err instanceof CancelledError) {
885
820
  if (untilDeadline.aborted) {
886
- blocked = `the guest probe was still in flight when the ${timeout_s}s deadline arrived`;
821
+ blocked = `the status read was still in flight when the ${timeout_s}s deadline arrived`;
887
822
  break;
888
823
  }
889
824
  blocked = err.message;
890
- await beat(`Waiting for ${id} — running; the platform could not be asked: ${blocked}`);
825
+ await beat(`Waiting for ${id} — the platform could not be asked: ${blocked}`);
891
826
  await sleep(POLL_MS, signal);
892
827
  continue;
893
828
  }
894
829
  if (!isTransientForPoll(err))
895
830
  throw err;
896
- // What actually refused, rather than one sentence for every
897
- // failure. A 409 IS the guest not being up yet — that is the
898
- // probe working, and it repeats the line beat in front of the
899
- // probe so the throttle holds it. A 503 is a hypervisor nobody
900
- // can reach and a transport abort is neither: saying "the guest
901
- // is not answering" over those tells the person watching the log
902
- // the one thing this channel exists to get right.
903
- //
904
- // And it SETS `blocked`, which it never did — not before this
905
- // change and not after the first version of it. A wait that spent
906
- // its whole window failing the probe on 503s gave up saying "was
907
- // last seen running" and named nothing, while the progress
908
- // channel had been reporting the hypervisor the entire time: the
909
- // same contradiction this comment set out to remove, pointed the
910
- // other way (/code-review). Deliberately not for the 409, where
911
- // the platform did answer and `blocked` would be a lie about it.
912
- if (!(err instanceof ConflictError)) {
913
- blocked = err instanceof Error ? err.message : String(err);
914
- }
915
- await beat(err instanceof ConflictError
916
- ? `Waiting for ${id} — running; asking the guest.`
917
- : `Waiting for ${id} — running; the platform could not be asked: ${blocked}`);
918
- // The guest probe's own failure decides this turn's interval, for
919
- // pollDelay's reason. The ordinary path below keeps POLL_MS.
831
+ blocked = err instanceof Error ? err.message : String(err);
832
+ await beat(`Waiting for ${id} — the platform could not be asked: ${blocked}`);
920
833
  await sleep(pollDelay(err), signal);
921
834
  continue;
922
835
  }
836
+ blocked = undefined;
837
+ session.noteResolution(id, c.resolution);
838
+ last = c.status ?? 'unknown';
839
+ // ONE beat per turn, and this is not it when a guest probe is about to
840
+ // run. Beating here and again in the probe's failure branch sent two
841
+ // notifications per poll — and because the two lines DIFFER, each read
842
+ // as news to the throttle and neither was ever held, so the steady
843
+ // state of the commonest long wait was twice the per-poll rate the
844
+ // interval exists to avoid (/code-review).
845
+ //
846
+ // Fixed by making the two say the SAME thing rather than by silencing
847
+ // this one, which was the first attempt and dropped the wrong half of
848
+ // the pair (/code-review again). The probe below is the longest call
849
+ // in the loop — an undici header timeout or a proxy 524 can hold it
850
+ // for minutes — so the beat that must survive is the one IN FRONT of
851
+ // it. The 409 branch repeats this line and the throttle holds it; a
852
+ // probe that fails some other way says so, which is news and goes out.
853
+ await beat(until === 'guest' && last === 'running'
854
+ ? `Waiting for ${id} — running; asking the guest.`
855
+ : `Waiting for ${id} — ${last}.`);
856
+ if (last === 'build-failed') {
857
+ // `refused`, for the reason `cancelled` is: the wait never reached
858
+ // what it was told to wait for, and this one never will. A caller
859
+ // reading `isError` to decide whether to go on would otherwise see
860
+ // a build that failed and a guest that answered as the same result.
861
+ //
862
+ // `build.source` is what the machine was built *from*, not why the
863
+ // build failed — printed bare after "Build failed:" it reads as the
864
+ // reason and names an image instead of a cause. `start_error` is
865
+ // the field that carries a diagnostic, so prefer it and label the
866
+ // source as the source when that is all there is.
867
+ const why = c.start_error
868
+ ? `: ${c.start_error}`
869
+ : c.build?.source
870
+ ? ` (built from ${c.build.source}) — the platform gave no reason`
871
+ : ' — the platform gave no reason';
872
+ return refused(`Build failed${why}. This does not resolve on its own.`, withoutCredentials(c));
873
+ }
874
+ // Its files were partly removed and its disk is gone: the platform
875
+ // refuses to start or use it, and only deleting it again clears it.
876
+ // Waiting spent the whole budget and then reported "last seen
877
+ // half-removed", which is the state the caller passed in (Codex
878
+ // review). Not qualified by the pool: nothing can be admitted for a
879
+ // machine with no disk.
880
+ if (last === 'half-removed') {
881
+ return refused(`${id} is half-removed: its files were partly removed, it cannot be started or used again, and delete_computer is what clears it.`, withoutCredentials(c));
882
+ }
883
+ // Neither of the next two resolves on its own, so spinning on either
884
+ // burns the whole timeout waiting for something nobody is going to do.
885
+ //
886
+ // Unless somebody is. `status` is read from the guest process, so a
887
+ // start that has been ADMITTED reads as `stopped` while it boots and
888
+ // as `suspended` while it resumes — the session record is spent only
889
+ // on the way out of a start that worked. Refusing there tells a model
890
+ // to call start_computer on a computer that is already starting, and
891
+ // the obvious next thing it does is start it a second time.
892
+ //
893
+ // nothingAdmitted is the platform's own word for idle, and it is
894
+ // absent-aware: a host that did not answer has not said nothing is
895
+ // coming, so the wait goes on rather than refusing (OPL-4631).
896
+ if (last === 'suspended' && nothingAdmitted(c)) {
897
+ 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
+ }
899
+ if (last === 'stopped' && nothingAdmitted(c)) {
900
+ return refused(`${id} is stopped. start_computer boots it.`, withoutCredentials(c));
901
+ }
902
+ if (last === 'running') {
903
+ if (until === 'running')
904
+ return said(`Running: ${describe(c)}`, withoutCredentials(c));
905
+ // "The guest is up" is not a status the platform reports, so it is
906
+ // asked rather than waited for: a trivial exec either answers, or
907
+ // refuses with the 409 that says the agent is not up yet.
908
+ try {
909
+ await api.send('POST', P.computerAction(id, 'exec'), {
910
+ body: P.execBody({ command: 'true', timeout_s: 5 }),
911
+ });
912
+ return said(`Guest is answering: ${describe(c)}`, withoutCredentials(c));
913
+ }
914
+ catch (err) {
915
+ // The same two deadlines as the status read above, and for the
916
+ // same reason: this catch used to judge the error alone, so a
917
+ // cancellation during the guest probe left the wait throwing what
918
+ // read as a platform outage instead of saying the caller had
919
+ // hung up. Half the loop knew to check the signal and half did
920
+ // not, which is the worse of the two ways to be inconsistent.
921
+ if (extra.signal?.aborted)
922
+ return cancelled(id, last);
923
+ if (err instanceof CancelledError) {
924
+ if (untilDeadline.aborted) {
925
+ blocked = `the guest probe was still in flight when the ${timeout_s}s deadline arrived`;
926
+ break;
927
+ }
928
+ blocked = err.message;
929
+ await beat(`Waiting for ${id} — running; the platform could not be asked: ${blocked}`);
930
+ await sleep(POLL_MS, signal);
931
+ continue;
932
+ }
933
+ if (!isTransientForPoll(err))
934
+ throw err;
935
+ // What actually refused, rather than one sentence for every
936
+ // failure. A 409 IS the guest not being up yet — that is the
937
+ // probe working, and it repeats the line beat in front of the
938
+ // probe so the throttle holds it. A 503 is a hypervisor nobody
939
+ // can reach and a transport abort is neither: saying "the guest
940
+ // is not answering" over those tells the person watching the log
941
+ // the one thing this channel exists to get right.
942
+ //
943
+ // And it SETS `blocked`, which it never did — not before this
944
+ // change and not after the first version of it. A wait that spent
945
+ // its whole window failing the probe on 503s gave up saying "was
946
+ // last seen running" and named nothing, while the progress
947
+ // channel had been reporting the hypervisor the entire time: the
948
+ // same contradiction this comment set out to remove, pointed the
949
+ // other way (/code-review). Deliberately not for the 409, where
950
+ // the platform did answer and `blocked` would be a lie about it.
951
+ if (!(err instanceof ConflictError)) {
952
+ blocked = err instanceof Error ? err.message : String(err);
953
+ }
954
+ await beat(err instanceof ConflictError
955
+ ? `Waiting for ${id} — running; asking the guest.`
956
+ : `Waiting for ${id} — running; the platform could not be asked: ${blocked}`);
957
+ // The guest probe's own failure decides this turn's interval, for
958
+ // pollDelay's reason. The ordinary path below keeps POLL_MS.
959
+ await sleep(pollDelay(err), signal);
960
+ continue;
961
+ }
962
+ }
963
+ await sleep(POLL_MS, signal);
923
964
  }
924
- await sleep(POLL_MS, signal);
965
+ // Also a refusal: the deadline passed without the condition being met,
966
+ // which is the same shape of answer as a cancellation and not the same
967
+ // as success. The message still says to call again, because the state
968
+ // it was waiting on may yet arrive.
969
+ return refused(blocked
970
+ ? `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.`);
972
+ }
973
+ finally {
974
+ await beat.stop();
925
975
  }
926
- // Also a refusal: the deadline passed without the condition being met,
927
- // which is the same shape of answer as a cancellation and not the same
928
- // as success. The message still says to call again, because the state
929
- // it was waiting on may yet arrive.
930
- return refused(blocked
931
- ? `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.`
932
- : `Gave up after ${timeout_s}s; ${id} was last seen ${last}. Nothing was changed — call again to keep waiting.`);
933
976
  }));
934
977
  server.registerTool('get_desktop_url', {
935
978
  title: 'Get a link to watch the desktop',
@@ -1117,6 +1160,9 @@ export const registerComputers = (server, session, opts) => {
1117
1160
  .optional()
1118
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.'),
1119
1162
  start: z.boolean().optional().describe('Boot it immediately. True by default.'),
1163
+ secrets: secretBindingsSchema(false)
1164
+ .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.`),
1120
1166
  },
1121
1167
  annotations: { destructiveHint: false, openWorldHint: true },
1122
1168
  }, (args, extra) => guarded(async () => {
@@ -1152,12 +1198,19 @@ export const registerComputers = (server, session, opts) => {
1152
1198
  : !originalSupportsContinuation
1153
1199
  ? 'The original create must include a nonblank template and omit size to continue with this token. Inspect the original request and preparation details; do not automatically wait or retry.'
1154
1200
  : 'The response does not supply a known continuation state, usable token, and valid delay. Inspect the preparation details; do not automatically wait or retry.';
1155
- return refused(`${typeof body.error === 'string' ? body.error : 'The template image is not available for this create.'} No computer has been created. ${advice} The token is not a create idempotency key. Never automatically replay after a lost or ambiguous response.`, {
1156
- code: body.code,
1157
- template_transfer: body.template_transfer,
1158
- preparation,
1159
- retry_after_ms: error.retryAfterMs,
1160
- });
1201
+ const refusal = withErrorMetadata(refused(`${typeof body.error === 'string' ? body.error : 'The template image is not available for this create.'} No computer has been created. ${advice} The token is not a create idempotency key. Never automatically replay after a lost or ambiguous response.`), error);
1202
+ return {
1203
+ ...refusal,
1204
+ content: [
1205
+ ...refusal.content,
1206
+ ...said('Template preparation:', {
1207
+ code: body.code,
1208
+ template_transfer: body.template_transfer,
1209
+ preparation,
1210
+ retry_after_ms: error.retryAfterMs,
1211
+ }).content,
1212
+ ],
1213
+ };
1161
1214
  }
1162
1215
  throw error;
1163
1216
  }