@testdriverai/mcp 7.10.0-canary.0 → 7.10.1-canary

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 (196) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/agent/index.js +116 -71
  3. package/agent/interface.js +13 -1
  4. package/agent/lib/commands.js +35 -15
  5. package/agent/lib/provision-commands.js +92 -11
  6. package/agent/lib/sandbox.js +263 -30
  7. package/agent/lib/sdk.js +16 -12
  8. package/agent/lib/system.js +107 -31
  9. package/ai/.claude-plugin/plugin.json +89 -4
  10. package/ai/agents/testdriver.md +4 -8
  11. package/ai/skills/testdriver-assert/SKILL.md +0 -1
  12. package/ai/skills/testdriver-aws-setup/SKILL.md +1 -1
  13. package/ai/skills/testdriver-caching/SKILL.md +13 -6
  14. package/ai/skills/testdriver-ci-cd/SKILL.md +57 -14
  15. package/ai/skills/testdriver-client/SKILL.md +10 -6
  16. package/ai/skills/testdriver-customizing-devices/SKILL.md +7 -5
  17. package/ai/skills/testdriver-dashcam/SKILL.md +36 -3
  18. package/ai/skills/testdriver-debugging-with-screenshots/SKILL.md +28 -4
  19. package/ai/skills/testdriver-device-config/SKILL.md +5 -5
  20. package/ai/skills/testdriver-find/SKILL.md +50 -11
  21. package/ai/skills/testdriver-generating-tests/SKILL.md +163 -8
  22. package/ai/skills/testdriver-hosted/SKILL.md +1 -1
  23. package/ai/skills/testdriver-machine-setup/SKILL.md +331 -0
  24. package/ai/skills/testdriver-making-assertions/SKILL.md +78 -2
  25. package/ai/skills/testdriver-mcp-workflow/SKILL.md +2 -2
  26. package/ai/skills/testdriver-parse/SKILL.md +1 -1
  27. package/ai/skills/testdriver-performing-actions/SKILL.md +97 -3
  28. package/ai/skills/testdriver-provision/SKILL.md +0 -20
  29. package/ai/skills/testdriver-quickstart/SKILL.md +299 -21
  30. package/ai/skills/testdriver-reusable-code/SKILL.md +3 -3
  31. package/ai/skills/testdriver-running-tests/SKILL.md +1 -1
  32. package/ai/skills/testdriver-screenshot/SKILL.md +5 -5
  33. package/ai/skills/testdriver-scroll/SKILL.md +30 -160
  34. package/ai/skills/testdriver-test-writer/SKILL.md +3 -5
  35. package/ai/skills/testdriver-testdriver/SKILL.md +3 -7
  36. package/ai/skills/testdriver-wait/SKILL.md +1 -1
  37. package/{docs/v7/ai.mdx → ai/skills/testdriver:ai/SKILL.md} +3 -4
  38. package/ai/skills/testdriver:assert/SKILL.md +315 -0
  39. package/ai/skills/testdriver:aws-setup/SKILL.md +448 -0
  40. package/ai/skills/testdriver:cache/SKILL.md +221 -0
  41. package/ai/skills/testdriver:caching/SKILL.md +124 -0
  42. package/ai/skills/testdriver:captcha/SKILL.md +158 -0
  43. package/ai/skills/testdriver:ci-cd/SKILL.md +659 -0
  44. package/ai/skills/testdriver:click/SKILL.md +286 -0
  45. package/ai/skills/testdriver:client/SKILL.md +477 -0
  46. package/ai/skills/testdriver:customizing-devices/SKILL.md +319 -0
  47. package/ai/skills/testdriver:dashcam/SKILL.md +451 -0
  48. package/ai/skills/testdriver:debugging-with-screenshots/SKILL.md +415 -0
  49. package/{docs/v7/device-config.mdx → ai/skills/testdriver:device-config/SKILL.md} +8 -8
  50. package/ai/skills/testdriver:double-click/SKILL.md +102 -0
  51. package/ai/skills/testdriver:elements/SKILL.md +605 -0
  52. package/ai/skills/testdriver:enterprise/SKILL.md +7 -0
  53. package/ai/skills/testdriver:errors/SKILL.md +246 -0
  54. package/ai/skills/testdriver:events/SKILL.md +356 -0
  55. package/ai/skills/testdriver:exec/SKILL.md +317 -0
  56. package/ai/skills/testdriver:find/SKILL.md +860 -0
  57. package/ai/skills/testdriver:focus-application/SKILL.md +293 -0
  58. package/ai/skills/testdriver:generating-tests/SKILL.md +25 -0
  59. package/ai/skills/testdriver:hosted/SKILL.md +156 -0
  60. package/ai/skills/testdriver:hover/SKILL.md +278 -0
  61. package/{docs/v7/locating-elements.mdx → ai/skills/testdriver:locating-elements/SKILL.md} +3 -3
  62. package/ai/skills/testdriver:making-assertions/SKILL.md +32 -0
  63. package/ai/skills/testdriver:mcp/SKILL.md +7 -0
  64. package/ai/skills/testdriver:mouse-down/SKILL.md +161 -0
  65. package/ai/skills/testdriver:mouse-up/SKILL.md +164 -0
  66. package/ai/skills/testdriver:parse/SKILL.md +236 -0
  67. package/ai/skills/testdriver:performing-actions/SKILL.md +53 -0
  68. package/ai/skills/testdriver:press-keys/SKILL.md +348 -0
  69. package/ai/skills/testdriver:provision/SKILL.md +331 -0
  70. package/ai/skills/testdriver:quickstart/SKILL.md +172 -0
  71. package/ai/skills/testdriver:redraw/SKILL.md +214 -0
  72. package/ai/skills/testdriver:reusable-code/SKILL.md +249 -0
  73. package/ai/skills/testdriver:right-click/SKILL.md +123 -0
  74. package/{docs/v7/running-tests.mdx → ai/skills/testdriver:running-tests/SKILL.md} +4 -4
  75. package/ai/skills/testdriver:screenshot/SKILL.md +248 -0
  76. package/ai/skills/testdriver:screenshots/SKILL.md +184 -0
  77. package/ai/skills/testdriver:scroll/SKILL.md +205 -0
  78. package/ai/skills/testdriver:secrets/SKILL.md +115 -0
  79. package/ai/skills/testdriver:self-hosted/SKILL.md +147 -0
  80. package/ai/skills/testdriver:test-results-json/SKILL.md +257 -0
  81. package/ai/skills/testdriver:testdriver/SKILL.md +624 -0
  82. package/ai/skills/testdriver:type/SKILL.md +357 -0
  83. package/ai/skills/testdriver:variables/SKILL.md +111 -0
  84. package/ai/skills/testdriver:wait/SKILL.md +50 -0
  85. package/{docs/v7/waiting-for-elements.mdx → ai/skills/testdriver:waiting-for-elements/SKILL.md} +3 -3
  86. package/ai/skills/testdriver:what-is-testdriver/SKILL.md +54 -0
  87. package/debugger/index.html +12 -0
  88. package/docs/_data/examples-manifest.json +54 -46
  89. package/docs/_data/examples-manifest.schema.json +12 -2
  90. package/docs/_scripts/extract-example-urls.js +21 -3
  91. package/docs/_scripts/generate-examples.js +64 -16
  92. package/docs/changelog.mdx +29 -2
  93. package/docs/claude-mcp-plugin.mdx +65 -18
  94. package/docs/docs.json +16 -33
  95. package/docs/snippets/tests/scroll-yaml.mdx +2 -2
  96. package/docs/v6/commands/scroll-until-image.mdx +1 -1
  97. package/docs/v6/commands/scroll-until-text.mdx +2 -2
  98. package/docs/v6/commands/scroll.mdx +2 -2
  99. package/docs/v7/_drafts/agents.mdx +0 -4
  100. package/docs/v7/_drafts/best-practices.mdx +0 -12
  101. package/docs/v7/_drafts/commands/scroll-until-image.mdx +1 -1
  102. package/docs/v7/_drafts/commands/scroll-until-text.mdx +2 -2
  103. package/docs/v7/_drafts/commands/scroll.mdx +1 -1
  104. package/docs/v7/_drafts/configuration.mdx +0 -12
  105. package/docs/v7/_drafts/faq.mdx +2 -10
  106. package/docs/v7/_drafts/readme.mdx +0 -1
  107. package/docs/v7/_drafts/troubleshooting.mdx +1 -4
  108. package/docs/v7/assert.mdx +0 -1
  109. package/docs/v7/aws-setup.mdx +1 -1
  110. package/docs/v7/caching.mdx +14 -10
  111. package/docs/v7/ci-cd.mdx +98 -14
  112. package/docs/v7/client.mdx +10 -6
  113. package/docs/v7/copilot/auto-healing.mdx +167 -18
  114. package/docs/v7/copilot/running-tests.mdx +915 -54
  115. package/docs/v7/customizing-devices.mdx +7 -5
  116. package/docs/v7/dashcam.mdx +36 -3
  117. package/docs/v7/debugging-with-screenshots.mdx +31 -7
  118. package/docs/v7/examples/ai.mdx +13 -8
  119. package/docs/v7/examples/assert.mdx +1 -1
  120. package/docs/v7/examples/chrome-extension.mdx +30 -13
  121. package/docs/v7/examples/element-not-found.mdx +1 -1
  122. package/docs/v7/examples/findall-coffee-icons.mdx +3 -2
  123. package/docs/v7/examples/formatted-logging.mdx +74 -0
  124. package/docs/v7/examples/hover-image.mdx +1 -1
  125. package/docs/v7/examples/hover-text-with-description.mdx +2 -2
  126. package/docs/v7/examples/hover-text.mdx +1 -1
  127. package/docs/v7/examples/installer.mdx +1 -1
  128. package/docs/v7/examples/launch-vscode-linux.mdx +27 -27
  129. package/docs/v7/examples/match-image.mdx +1 -1
  130. package/docs/v7/examples/parse.mdx +3 -2
  131. package/docs/v7/examples/press-keys.mdx +2 -2
  132. package/docs/v7/examples/scroll-keyboard.mdx +2 -2
  133. package/docs/v7/examples/scroll-until-text.mdx +1 -1
  134. package/docs/v7/examples/scroll.mdx +1 -1
  135. package/docs/v7/examples/type.mdx +1 -1
  136. package/docs/v7/find.mdx +50 -11
  137. package/docs/v7/generating-tests.mdx +166 -10
  138. package/docs/v7/hosted.mdx +1 -1
  139. package/docs/v7/making-assertions.mdx +81 -4
  140. package/docs/v7/parse.mdx +1 -1
  141. package/docs/v7/performing-actions.mdx +100 -5
  142. package/docs/v7/provision.mdx +0 -20
  143. package/docs/v7/quickstart.mdx +299 -21
  144. package/docs/v7/reusable-code.mdx +3 -3
  145. package/docs/v7/screenshot.mdx +5 -5
  146. package/docs/v7/scroll.mdx +30 -160
  147. package/docs/v7/wait.mdx +1 -1
  148. package/examples/chrome-extension.test.mjs +29 -12
  149. package/examples/config.mjs +1 -1
  150. package/examples/launch-vscode-linux.test.mjs +25 -25
  151. package/interfaces/cli/commands/init.js +84 -2
  152. package/interfaces/vitest-plugin.mjs +51 -61
  153. package/lib/core/Dashcam.js +43 -2
  154. package/lib/core/index.d.ts +13 -1
  155. package/lib/github-comment.mjs +11 -5
  156. package/lib/init-project.js +98 -44
  157. package/lib/install-clients.js +470 -0
  158. package/lib/provision.js +749 -0
  159. package/lib/resolve-channel.js +2 -2
  160. package/lib/sentry.js +15 -2
  161. package/lib/vitest/hooks.mjs +44 -34
  162. package/mcp-server/README.md +32 -0
  163. package/mcp-server/dist/core/actions.d.ts +176 -0
  164. package/mcp-server/dist/core/actions.js +753 -0
  165. package/mcp-server/dist/env-utils.d.ts +45 -0
  166. package/mcp-server/dist/env-utils.js +63 -0
  167. package/mcp-server/dist/provision-types.d.ts +6 -2
  168. package/mcp-server/dist/provision-types.js +3 -1
  169. package/mcp-server/dist/server.mjs +449 -751
  170. package/mcp-server/package-lock.json +384 -1
  171. package/mcp-server/package.json +5 -2
  172. package/mcp-server/src/core/actions.ts +912 -0
  173. package/mcp-server/src/env-utils.test.ts +82 -0
  174. package/mcp-server/src/env-utils.ts +77 -0
  175. package/mcp-server/src/provision-types.ts +4 -1
  176. package/mcp-server/src/server.ts +533 -954
  177. package/mcp-server/tsconfig.json +1 -1
  178. package/mcp-server/vitest.config.ts +7 -0
  179. package/package.json +15 -3
  180. package/sdk.d.ts +11 -9
  181. package/sdk.js +116 -746
  182. package/setup/aws/spawn-runner.sh +24 -3
  183. package/docs/v7/_drafts/caching-ai.mdx +0 -215
  184. package/docs/v7/_drafts/caching.mdx +0 -366
  185. package/docs/v7/_drafts/prompt-cache.mdx +0 -200
  186. package/docs/v7/copilot/creating-tests.mdx +0 -156
  187. package/docs/v7/copilot/github.mdx +0 -143
  188. package/docs/v7/copilot/setup.mdx +0 -143
  189. package/docs/v7/examples/exec-output.mdx +0 -84
  190. package/docs/v7/examples/exec-pwsh.mdx +0 -82
  191. package/docs/v7/examples/focus-window.mdx +0 -61
  192. package/docs/v7/examples/scroll-until-image.mdx +0 -82
  193. package/docs/v7/examples/windows-installer.mdx +0 -95
  194. package/examples/formatted-logging.test.mjs +0 -27
  195. package/examples/match-image.test.mjs +0 -55
  196. package/manual/exec-stream-logs.test.mjs +0 -25
@@ -6,6 +6,17 @@ const { version } = require("../../package.json");
6
6
  const { withRetry, getSentryTraceHeaders } = require("./sdk");
7
7
  const sentry = require("../../lib/sentry");
8
8
 
9
+ // How long (ms) to keep polling for a free concurrency slot before giving up.
10
+ // Configurable via TD_CONCURRENCY_MAX_WAIT (in seconds). Defaults to 60s.
11
+ // A value of 0 (or negative) disables polling entirely — fail on first denial.
12
+ function resolveConcurrencyMaxWait() {
13
+ var raw = process.env.TD_CONCURRENCY_MAX_WAIT;
14
+ if (raw === undefined || raw === "") return 60000;
15
+ var seconds = Number(raw);
16
+ if (!Number.isFinite(seconds) || seconds < 0) return 60000;
17
+ return Math.round(seconds * 1000);
18
+ }
19
+
9
20
  const createSandbox = function (emitter, analytics, sessionInstance) {
10
21
  class Sandbox {
11
22
  constructor() {
@@ -372,7 +383,37 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
372
383
  step: 'discontinuity',
373
384
  message: 'Recovering missed messages after connection interruption...',
374
385
  });
375
- self._recoverFromDiscontinuity();
386
+ self._recoverFromDiscontinuity().catch(function (err) {
387
+ logger.debug('[realtime] Discontinuity recovery error (suppressed): ' + (err.message || err));
388
+ });
389
+ }
390
+
391
+ // When the channel is detached or failed (e.g. sandbox terminated — 404/90001),
392
+ // reject all pending promises immediately so callers don't hang for the full
393
+ // command timeout (up to 5 minutes for dashcam stop).
394
+ if (current === 'detached' || current === 'failed') {
395
+ var pendingIds = Object.keys(self.ps);
396
+ if (pendingIds.length > 0) {
397
+ logger.warn(
398
+ '[realtime] Channel ' + current + ' with ' + pendingIds.length +
399
+ ' pending request(s) — rejecting immediately' +
400
+ (reasonMsg ? ' (reason: ' + reasonMsg + ')' : '')
401
+ );
402
+ var channelErr = new Error(
403
+ 'Channel ' + current + (reasonMsg ? ': ' + reasonMsg : '') +
404
+ ' — sandbox session may have terminated'
405
+ );
406
+ channelErr.name = 'NotFoundError';
407
+ if (reason && reason.code) channelErr.code = reason.code;
408
+ for (var pi = 0; pi < pendingIds.length; pi++) {
409
+ var entry = self.ps[pendingIds[pi]];
410
+ if (entry) {
411
+ delete self.ps[pendingIds[pi]];
412
+ delete self._execBuffers[pendingIds[pi]];
413
+ entry.reject(channelErr);
414
+ }
415
+ }
416
+ }
376
417
  }
377
418
  });
378
419
  }
@@ -433,6 +474,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
433
474
  */
434
475
  async _httpPostWithConcurrencyRetry(path, body, timeout) {
435
476
  var concurrencyRetryInterval = 10000; // 10 seconds between concurrency retries
477
+ var concurrencyMaxWait = resolveConcurrencyMaxWait(); // give up polling for a slot after this many ms
436
478
  var startTime = Date.now();
437
479
  var sessionId = this.sessionInstance ? this.sessionInstance.get() : null;
438
480
 
@@ -476,6 +518,15 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
476
518
  var responseData = err.response && err.response.data;
477
519
  if (responseData && responseData.errorCode === "CONCURRENCY_LIMIT_EXCEEDED") {
478
520
  var elapsed = Date.now() - startTime;
521
+ if (elapsed >= concurrencyMaxWait) {
522
+ var giveUpErr = new Error(
523
+ (responseData.errorMessage || responseData.message || "Concurrency limit reached") +
524
+ " — gave up after " + Math.round(elapsed / 1000) + "s " +
525
+ "(max wait " + (concurrencyMaxWait / 1000) + "s).",
526
+ );
527
+ giveUpErr.responseData = responseData;
528
+ throw giveUpErr;
529
+ }
479
530
  logger.log(
480
531
  "Concurrency limit reached — waiting " +
481
532
  concurrencyRetryInterval / 1000 +
@@ -584,6 +635,10 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
584
635
  // On slot-denied, we poll forever (re-calling authenticate every 10s)
585
636
  // until a slot opens, matching _httpPostWithConcurrencyRetry behavior.
586
637
  var concurrencyRetryInterval = 10000;
638
+ // Overall cap on how long we'll keep polling for a free slot before
639
+ // giving up. Keeps a queued agent from hanging forever when the account
640
+ // is at its concurrency limit. Configurable via TD_CONCURRENCY_MAX_WAIT.
641
+ var concurrencyMaxWait = resolveConcurrencyMaxWait();
587
642
  var slotPollStart = Date.now();
588
643
  while (reply.status === 'pending') {
589
644
  logger.log('Slot claim pending — waiting for approval via Ably...');
@@ -659,12 +714,27 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
659
714
  // Slot denied — disconnect Ably and re-try the full authenticate
660
715
  // flow after a delay, polling forever until a slot opens.
661
716
  var elapsed = Date.now() - slotPollStart;
717
+ if (elapsed >= concurrencyMaxWait) {
718
+ try {
719
+ if (this._ably) this._ably.close();
720
+ this._ably = null;
721
+ this._sessionChannel = null;
722
+ } catch (_) {}
723
+ var giveUpErr = new Error(
724
+ 'Slot denied: ' + (slotDecision.data.message || 'concurrency limit reached') +
725
+ ' — gave up after ' + Math.round(elapsed / 1000) + 's ' +
726
+ '(max wait ' + (concurrencyMaxWait / 1000) + 's). ' +
727
+ 'Upgrade for more slots → https://console.testdriver.ai/checkout/pro'
728
+ );
729
+ giveUpErr.responseData = slotDecision.data;
730
+ throw giveUpErr;
731
+ }
662
732
  logger.log(
663
733
  'Slot denied: ' + (slotDecision.data.message || 'concurrency limit reached') +
664
734
  ' — waiting ' + (concurrencyRetryInterval / 1000) + 's before retrying' +
665
735
  ' (' + Math.round(elapsed / 1000) + 's elapsed)...'
666
736
  );
667
- logger.log('Upgrade for more slots → https://console.testdriver.ai/checkout/team');
737
+ logger.log('Upgrade for more slots → https://console.testdriver.ai/checkout/pro');
668
738
  try {
669
739
  if (this._ably) this._ably.close();
670
740
  this._ably = null;
@@ -741,14 +811,160 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
741
811
  break; // slot approved and provisioned — exit the while loop
742
812
  }
743
813
 
814
+ // ─── Handle async provisioning status ─────────────────────────────
815
+ // API may return early with status: 'provisioning' while a background
816
+ // job is still creating/configuring the sandbox. Prefer Ably control
817
+ // events for completion and only fall back to authenticate polling when
818
+ // an event is not observed in time.
819
+ var provisioningPollStart = Date.now();
820
+ var provisioningTimeoutMs = 10 * 60 * 1000;
821
+ while (reply.status === 'provisioning') {
822
+ var provisionElapsed = Date.now() - provisioningPollStart;
823
+ if (provisionElapsed >= provisioningTimeoutMs) {
824
+ var provisioningTimeoutErr = new Error(
825
+ "Sandbox provisioning timed out after " +
826
+ Math.round(provisionElapsed / 1000) +
827
+ "s" +
828
+ (this._sandboxId || (reply && reply.sandboxId)
829
+ ? " for sandbox " + (this._sandboxId || (reply && reply.sandboxId))
830
+ : "") +
831
+ ". Last known status: provisioning"
832
+ );
833
+ provisioningTimeoutErr.responseData = reply;
834
+ throw provisioningTimeoutErr;
835
+ }
836
+ logger.log(
837
+ 'Waiting for sandbox to be ready...'
838
+ );
839
+
840
+ var self = this;
841
+ var provisioningEvent = null;
842
+
843
+ if (this._sessionChannel) {
844
+ provisioningEvent = await new Promise(function (resolve) {
845
+ var resolved = false;
846
+ var eventTimeout = 30000;
847
+
848
+ function finish(data) {
849
+ if (resolved) return;
850
+ resolved = true;
851
+ clearTimeout(timer);
852
+ try { self._sessionChannel.unsubscribe('control', onProvisionCtrl); } catch (_) {}
853
+ resolve(data || null);
854
+ }
855
+
856
+ function onProvisionCtrl(msg) {
857
+ var data = msg && msg.data;
858
+ if (!data) return;
859
+ if (data.type === 'provisioning.started') {
860
+ logger.log((data.message || 'Provisioning started') + (data.os ? ' (' + data.os + ')' : ''));
861
+ return;
862
+ }
863
+ if (data.type === 'provisioning.progress') {
864
+ var progress = data.message || ('Provisioning step: ' + (data.phase || 'in-progress'));
865
+ logger.log(progress);
866
+ return;
867
+ }
868
+ if (data.type === 'provisioning.completed' || data.type === 'provisioning.failed') {
869
+ finish(data);
870
+ }
871
+ }
872
+
873
+ var timer = setTimeout(function () {
874
+ finish(null);
875
+ }, eventTimeout);
876
+ if (timer.unref) timer.unref();
877
+
878
+ try {
879
+ self._sessionChannel.subscribe('control', onProvisionCtrl);
880
+
881
+ // Check recent history to close race window where event was
882
+ // published before this subscription was attached.
883
+ self._sessionChannel.history({ limit: 20 }).then(function (page) {
884
+ if (!page || !page.items || resolved) return;
885
+ for (var i = 0; i < page.items.length; i++) {
886
+ var item = page.items[i];
887
+ var data = item && item.data;
888
+ if (item && item.name === 'control' && data && (data.type === 'provisioning.completed' || data.type === 'provisioning.failed')) {
889
+ finish(data);
890
+ return;
891
+ }
892
+ }
893
+ }).catch(function (err) {
894
+ logger.warn('Provisioning history lookup failed (non-fatal): ' + (err.message || err));
895
+ });
896
+ } catch (subscribeErr) {
897
+ logger.warn('Provisioning event subscribe failed (non-fatal): ' + (subscribeErr.message || subscribeErr));
898
+ finish(null);
899
+ }
900
+ });
901
+ }
902
+
903
+ if (provisioningEvent && provisioningEvent.type === 'provisioning.failed') {
904
+ var eventErr = new Error(
905
+ provisioningEvent.errorMessage || 'Failed while waiting for sandbox provisioning',
906
+ );
907
+ eventErr.responseData = provisioningEvent;
908
+ throw eventErr;
909
+ }
910
+
911
+ if (provisioningEvent && provisioningEvent.type === 'provisioning.completed') {
912
+ // Event carries the final payload shape from the API, so we can stop
913
+ // polling authenticate in the common case.
914
+ reply = Object.assign({}, reply, provisioningEvent);
915
+ if (reply.status === 'provisioning') {
916
+ reply.status = 'ready';
917
+ }
918
+ if (reply.success !== true) {
919
+ reply.success = true;
920
+ }
921
+ break;
922
+ }
923
+
924
+ await new Promise(function (resolve) {
925
+ var t = setTimeout(resolve, 10000);
926
+ if (t.unref) t.unref();
927
+ });
928
+
929
+ var pollBody = {
930
+ apiKey: this.apiKey,
931
+ version: version,
932
+ os: message.os || this.os || 'linux',
933
+ session: sessionId,
934
+ apiRoot: this.apiRoot,
935
+ sandboxId: this._sandboxId || (reply && reply.sandboxId),
936
+ slotApproved: true,
937
+ };
938
+ if (message.resolution) pollBody.resolution = message.resolution;
939
+ if (message.ci) pollBody.ci = message.ci;
940
+ if (message.ami) pollBody.ami = message.ami;
941
+ if (message.instanceType) pollBody.instanceType = message.instanceType;
942
+ if (message.e2bTemplateId) pollBody.e2bTemplateId = message.e2bTemplateId;
943
+ if (message.keepAlive !== undefined) pollBody.keepAlive = message.keepAlive;
944
+
945
+ reply = await this._httpPostWithConcurrencyRetry(
946
+ "/api/v7/sandbox/authenticate",
947
+ pollBody,
948
+ timeout,
949
+ );
950
+
951
+ if (!reply.success && reply.status !== 'provisioning') {
952
+ var provisioningErr = new Error(
953
+ reply.errorMessage || "Failed while waiting for sandbox provisioning",
954
+ );
955
+ provisioningErr.responseData = reply;
956
+ throw provisioningErr;
957
+ }
958
+ }
959
+
744
960
  if (message.type === "create") {
745
961
  // E2B (Linux) sandboxes return a url directly.
746
962
  // We still need to wait for runner.ready since sandbox-agent.js runs inside E2B.
747
963
  const isE2B = !!reply.url;
748
-
749
- const runnerIp = reply.runner && reply.runner.ip;
750
- const noVncPort = reply.runner && reply.runner.noVncPort;
751
- const runnerVncUrl = reply.runner && reply.runner.vncUrl;
964
+
965
+ let runnerIp = reply.runner && reply.runner.ip;
966
+ let noVncPort = reply.runner && reply.runner.noVncPort;
967
+ let runnerVncUrl = reply.runner && reply.runner.vncUrl;
752
968
 
753
969
  // Log image version info (AMI for Windows, E2B template for Linux)
754
970
  if (reply.imageVersion) {
@@ -771,12 +987,12 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
771
987
  // For presence-based Windows runners (reply.runner already set), the runner
772
988
  // is already listening so we can skip the wait.
773
989
  var self = this;
774
- const needsReadyWait = this._sessionChannel && (isE2B || !reply.runner);
990
+ const needsReadyWait = this._sessionChannel && (isE2B || !reply.runner || (reply.runner && reply.runner.os === 'windows'));
775
991
  if (needsReadyWait) {
776
992
  logger.log('Waiting for runner agent to signal readiness...');
777
- // E2B (Linux) sandboxes need extra time: S3 upload + npm install can add 60-120s on top of sandbox boot
778
- // EC2 (Windows) cold starts can be slow due to AV scanning and native module loading
779
- var readyTimeout = isE2B ? 300000 : 180000; // 5 min for E2B (S3+npm), 3 min for EC2
993
+ // E2B (Linux) sandboxes need extra time: S3 upload + npm install can add 60-120s on top of sandbox boot.
994
+ // Hosted EC2 (Windows) can also take several minutes when launching/provisioning in background.
995
+ var readyTimeout = isE2B ? 300000 : 300000; // 5 min for E2B and EC2
780
996
  await new Promise(function (resolve, reject) {
781
997
  var resolved = false;
782
998
  var waitStart = Date.now();
@@ -787,7 +1003,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
787
1003
  clearInterval(progressTimer);
788
1004
  self._sessionChannel.unsubscribe('control', onCtrl);
789
1005
  // Update runner info if provided
790
- if (data && data.os) reply.runner = reply.runner || {};
1006
+ if (data && (data.os || data.ip)) reply.runner = reply.runner || {};
791
1007
  if (data && data.os && reply.runner) reply.runner.os = data.os;
792
1008
  if (data && data.ip && reply.runner) reply.runner.ip = data.ip;
793
1009
  if (data && data.runnerVersion && reply.runner) reply.runner.version = data.runnerVersion;
@@ -853,11 +1069,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
853
1069
  // Also check channel history in case runner.ready was published
854
1070
  // before we subscribed (race condition on fast-booting agents).
855
1071
  try {
856
- self._sessionChannel.history({ limit: 50 }, function (err, page) {
857
- if (err) {
858
- logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
859
- return;
860
- }
1072
+ self._sessionChannel.history({ limit: 50 }).then(function (page) {
861
1073
  if (page && page.items) {
862
1074
  for (var i = 0; i < page.items.length; i++) {
863
1075
  var item = page.items[i];
@@ -868,12 +1080,21 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
868
1080
  }
869
1081
  }
870
1082
  }
1083
+ }).catch(function (err) {
1084
+ logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
871
1085
  });
872
1086
  } catch (histErr) {
873
1087
  logger.warn('History call threw (non-fatal): ' + (histErr.message || histErr));
874
1088
  }
875
1089
  });
876
1090
  }
1091
+
1092
+ // Refresh runner metadata after runner.ready wait because the wait handler
1093
+ // can populate reply.runner fields from control messages.
1094
+ runnerIp = reply.runner && reply.runner.ip;
1095
+ noVncPort = reply.runner && reply.runner.noVncPort;
1096
+ runnerVncUrl = reply.runner && reply.runner.vncUrl;
1097
+
877
1098
  // Prefer the full vncUrl reported by the runner (infrastructure-agnostic).
878
1099
  // For E2B sandboxes, use the url from the API reply.
879
1100
  // Fall back to constructing from ip + noVncPort for older runners.
@@ -1005,11 +1226,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
1005
1226
  // Also check channel history in case runner.ready was published
1006
1227
  // before we subscribed (race condition on fast-booting agents).
1007
1228
  try {
1008
- self._sessionChannel.history({ limit: 50 }, function (err, page) {
1009
- if (err) {
1010
- logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
1011
- return;
1012
- }
1229
+ self._sessionChannel.history({ limit: 50 }).then(function (page) {
1013
1230
  if (page && page.items) {
1014
1231
  for (var i = 0; i < page.items.length; i++) {
1015
1232
  var item = page.items[i];
@@ -1020,6 +1237,8 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
1020
1237
  }
1021
1238
  }
1022
1239
  }
1240
+ }).catch(function (err) {
1241
+ logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
1023
1242
  });
1024
1243
  } catch (histErr) {
1025
1244
  logger.warn('History call threw (non-fatal): ' + (histErr.message || histErr));
@@ -1204,9 +1423,11 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
1204
1423
  startTime: Date.now(),
1205
1424
  };
1206
1425
 
1207
- if (message.type === "output") {
1208
- p.catch(function () { });
1209
- }
1426
+ // Prevent unhandled rejections from late Ably responses that arrive
1427
+ // after the caller has moved on (e.g. test retry, cleanup). The await
1428
+ // chain still receives the rejection; this just marks the promise as
1429
+ // "handled" so Node does not emit unhandledRejection.
1430
+ p.catch(function () { });
1210
1431
 
1211
1432
  this._throttledPublish(this._sessionChannel, "command", message)
1212
1433
  .then(function () {
@@ -1255,12 +1476,12 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
1255
1476
  var rate = (this._publishCount / windowElapsed) * 1000;
1256
1477
  var rateStr = rate.toFixed(1);
1257
1478
 
1258
- // Log rate - warning if approaching limit, debug otherwise
1259
- if (rate > 45) {
1260
- logger.warn("Ably publish rate: " + rateStr + " msg/sec (approaching 50/sec limit)");
1261
- } else if (process.env.VERBOSE || process.env.TD_DEBUG) {
1262
- logger.log("Ably publish rate: " + rateStr + " msg/sec");
1263
- }
1479
+ // // Log rate - warning if approaching limit, debug otherwise
1480
+ // if (rate > 45) {
1481
+ // logger.warn("Ably publish rate: " + rateStr + " msg/sec (approaching 50/sec limit)");
1482
+ // } else if (process.env.VERBOSE || process.env.TD_DEBUG) {
1483
+ // logger.log("Ably publish rate: " + rateStr + " msg/sec");
1484
+ // }
1264
1485
 
1265
1486
  // Reset window
1266
1487
  this._publishCount = 0;
@@ -1441,6 +1662,18 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
1441
1662
  this.authenticated = false;
1442
1663
  this.instance = null;
1443
1664
  this._lastConnectParams = null;
1665
+
1666
+ // Reject all pending promises so active callers get a clear error
1667
+ // instead of hanging forever. The safety .catch() in _sendAbly()
1668
+ // ensures these rejections never surface as unhandled.
1669
+ var pendingIds = Object.keys(this.ps);
1670
+ var closedError = new Error('Sandbox connection closed');
1671
+ for (var i = 0; i < pendingIds.length; i++) {
1672
+ var entry = this.ps[pendingIds[i]];
1673
+ if (entry && typeof entry.reject === 'function') {
1674
+ try { entry.reject(closedError); } catch (_) { /* already settled */ }
1675
+ }
1676
+ }
1444
1677
  this.ps = {};
1445
1678
  }
1446
1679
 
package/agent/lib/sdk.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const { events } = require("../events");
2
2
  const { getSentryTraceHeaders } = require("./http");
3
+ const sentry = require("../../lib/sentry");
3
4
 
4
5
  // get the version from package.json
5
6
  const { version } = require("../../package.json");
@@ -20,6 +21,7 @@ const DEFAULT_RETRY_CONFIG = {
20
21
  'ENOTFOUND',
21
22
  'ENETUNREACH',
22
23
  'ERR_NETWORK',
24
+ 'ERR_BAD_RESPONSE',
23
25
  'ECONNABORTED',
24
26
  'EPIPE',
25
27
  'EAI_AGAIN',
@@ -35,14 +37,7 @@ const DEFAULT_RETRY_CONFIG = {
35
37
  * @returns {boolean} Whether the request should be retried
36
38
  */
37
39
  function isRetryableError(error, config = DEFAULT_RETRY_CONFIG) {
38
- // Network-level errors (no response received)
39
- if (!error.response) {
40
- return config.retryableNetworkCodes.includes(error.code);
41
- }
42
-
43
- // HTTP status code based retries
44
- const status = error.response?.status;
45
- return config.retryableStatusCodes.includes(status);
40
+ return true;
46
41
  }
47
42
 
48
43
  /**
@@ -102,6 +97,18 @@ async function withRetry(fn, options = {}) {
102
97
 
103
98
  const delayMs = calculateRetryDelay(attempt, error, config);
104
99
 
100
+ // Log retry errors to Sentry for visibility
101
+ sentry.captureException(error, {
102
+ tags: { phase: 'retry', attempt: attempt + 1 },
103
+ extra: {
104
+ maxRetries: config.maxRetries,
105
+ delayMs,
106
+ code: error.code,
107
+ status: error.response?.status,
108
+ url: error.config?.url?.replace(/X-Amz-\w+=[\w%]+/g, 'X-Amz-***=REDACTED'),
109
+ },
110
+ });
111
+
105
112
  // Call onRetry callback if provided
106
113
  if (options.onRetry) {
107
114
  options.onRetry(attempt + 1, error, delayMs);
@@ -395,10 +402,7 @@ const createSDK = (emitter, config, sessionInstance) => {
395
402
  const savedKB = (data.image.length / 1024).toFixed(0);
396
403
  delete data.image;
397
404
  data.imageKey = imageKey;
398
- emitter.emit(events.log?.debug || events.sdk.request, {
399
- path,
400
- message: `[sdk] uploaded screenshot to S3 (saved ${savedKB}KB inline), imageKey=${imageKey}`,
401
- });
405
+
402
406
  }
403
407
  } catch (uploadErr) {
404
408
  // Non-fatal: fall back to sending base64 inline
@@ -35,45 +35,80 @@ const createSystem = (emitter, sandbox, config) => {
35
35
  throw new Error("No downloadUrl in response: " + JSON.stringify(response.data));
36
36
  }
37
37
 
38
- // Step 2: Download the image from S3
39
- const imageResponse = await axios({
40
- method: "get",
41
- url: downloadUrl,
42
- responseType: "arraybuffer",
43
- timeout: 30000,
44
- });
38
+ // Step 2: Download the image from S3 (with retry)
39
+ // Short timeout + many retries: fail fast on stuck connections,
40
+ // and give Tigris time to replicate the object between attempts
41
+ const imageResponse = await withRetry(
42
+ () => axios({
43
+ method: "get",
44
+ url: downloadUrl,
45
+ responseType: "arraybuffer",
46
+ timeout: 10000,
47
+ }),
48
+ {
49
+ retryConfig: { maxRetries: 3, baseDelayMs: 1000 },
50
+ },
51
+ );
45
52
 
46
53
  return Buffer.from(imageResponse.data).toString("base64");
47
54
  };
48
55
 
49
- const screenshot = async (options) => {
50
- let response = await sandbox.send({
56
+ // Capture a screenshot from the runner. Returns the raw runner response,
57
+ // which is one of:
58
+ // { s3Key, width, height } — runner uploaded to S3 (Ably 64KB limit)
59
+ // { base64 } — direct/local connection, bytes inline
60
+ const captureRaw = async () => {
61
+ return await sandbox.send({
51
62
  type: "system.screenshot",
52
63
  });
64
+ };
53
65
 
54
- let base64;
66
+ const screenshot = async (options, rawResponse) => {
67
+ const MAX_RETRIES = 3;
68
+ let lastError;
55
69
 
56
- // Runner returns { s3Key } for Ably (screenshots too large for 64KB limit)
57
- // Runner returns { base64 } for direct/local connections
58
- if (response.s3Key) {
59
- base64 = await downloadFromS3(response.s3Key);
60
- } else {
61
- base64 = response.base64;
62
- }
70
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
71
+ try {
72
+ // Reuse a response captured by the caller (so the key path and the
73
+ // base64 path don't each trigger a separate runner capture); otherwise
74
+ // capture fresh.
75
+ let response = attempt === 0 && rawResponse
76
+ ? rawResponse
77
+ : await captureRaw();
63
78
 
64
- if (!base64) {
65
- throw new Error("Failed to take screenshot: sandbox returned empty data");
66
- }
67
-
68
- let image = Buffer.from(base64, "base64");
69
-
70
- // Verify we got actual image data (PNG header starts with these bytes)
71
- if (image.length < 100) {
72
- throw new Error(`Failed to take screenshot: received only ${image.length} bytes`);
79
+ let base64;
80
+
81
+ // Runner returns { s3Key } for Ably (screenshots too large for 64KB limit)
82
+ // Runner returns { base64 } for direct/local connections
83
+ if (response.s3Key) {
84
+ base64 = await downloadFromS3(response.s3Key);
85
+ } else {
86
+ base64 = response.base64;
87
+ }
88
+
89
+ if (!base64) {
90
+ throw new Error("Failed to take screenshot: sandbox returned empty data");
91
+ }
92
+
93
+ let image = Buffer.from(base64, "base64");
94
+
95
+ // Verify we got actual image data (PNG header starts with these bytes)
96
+ if (image.length < 100) {
97
+ throw new Error(`Failed to take screenshot: received only ${image.length} bytes`);
98
+ }
99
+
100
+ fs.writeFileSync(options.filename, image);
101
+ return { filename: options.filename };
102
+ } catch (error) {
103
+ lastError = error;
104
+ if (attempt < MAX_RETRIES) {
105
+ const delay = 1000 * Math.pow(2, attempt);
106
+ await new Promise((resolve) => setTimeout(resolve, delay));
107
+ }
108
+ }
73
109
  }
74
-
75
- fs.writeFileSync(options.filename, image);
76
- return { filename: options.filename };
110
+
111
+ throw lastError;
77
112
  };
78
113
 
79
114
  let primaryDisplay = null;
@@ -90,7 +125,7 @@ const createSystem = (emitter, sandbox, config) => {
90
125
  return path.join(os.tmpdir(), `td-${Date.now()}-${randomUUID().slice(0, 8)}-${countImages}.png`);
91
126
  };
92
127
 
93
- const captureAndResize = async (scale = 1, silent = false, mouse = false) => {
128
+ const captureAndResize = async (scale = 1, silent = false, mouse = false, rawResponse = null) => {
94
129
  try {
95
130
  if (!silent) {
96
131
  emitter.emit(events.screenCapture.start, {
@@ -103,7 +138,7 @@ const createSystem = (emitter, sandbox, config) => {
103
138
  let step1 = tmpFilename();
104
139
  let step2 = tmpFilename();
105
140
 
106
- await screenshot({ filename: step1, format: "png" });
141
+ await screenshot({ filename: step1, format: "png" }, rawResponse);
107
142
 
108
143
  // Load the screenshot image with Jimp
109
144
  let image = await Jimp.read(step1);
@@ -165,6 +200,46 @@ const createSystem = (emitter, sandbox, config) => {
165
200
  return await captureAndResize(scale, silent, mouse);
166
201
  };
167
202
 
203
+ // Build the image payload to send to the API for a command (find/assert/etc).
204
+ //
205
+ // Fast path: when the runner uploaded the screenshot to S3 and it was already
206
+ // captured at the requested resolution, return { imageKey } so the API reads
207
+ // the bytes straight from S3 by key. This skips the redundant round-trip the
208
+ // base64 path pays per command — SDK download from S3, Jimp re-encode, then a
209
+ // re-upload on the API side.
210
+ //
211
+ // Slow path (fallback): when bytes are inline (local/direct connection), when
212
+ // a mouse cursor must be composited, when scale != 1, or when the captured
213
+ // size differs from TD_RESOLUTION (so a resize is actually required), fall
214
+ // back to capturing + resizing locally and return { image } (base64).
215
+ const captureScreenImage = async (scale = 1, silent = false, mouse = false) => {
216
+ const raw = await captureRaw();
217
+
218
+ const [targetW, targetH] = config.TD_RESOLUTION || [];
219
+ const canUseKey =
220
+ raw &&
221
+ raw.s3Key &&
222
+ !mouse &&
223
+ scale === 1 &&
224
+ typeof raw.width === "number" &&
225
+ typeof raw.height === "number" &&
226
+ raw.width === targetW &&
227
+ raw.height === targetH;
228
+
229
+ if (canUseKey) {
230
+ if (!silent) {
231
+ emitter.emit(events.screenCapture.start, { scale, silent, display: primaryDisplay });
232
+ emitter.emit(events.screenCapture.end, { scale, silent, display: primaryDisplay });
233
+ }
234
+ return { imageKey: raw.s3Key };
235
+ }
236
+
237
+ // Fallback: download/resize locally and send base64. Pass the already
238
+ // captured runner response through so we don't capture the screen twice.
239
+ const step2 = await captureAndResize(scale, silent, mouse, raw);
240
+ return { image: fs.readFileSync(step2, "base64") };
241
+ };
242
+
168
243
  const platform = () => {
169
244
  return "windows";
170
245
  };
@@ -191,6 +266,7 @@ const createSystem = (emitter, sandbox, config) => {
191
266
  return {
192
267
  captureScreenBase64,
193
268
  captureScreenPNG,
269
+ captureScreenImage,
194
270
  getMousePosition,
195
271
  primaryDisplay,
196
272
  activeWin,