testdriverai 7.8.0-test.9 → 7.9.0-test.1

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 (56) hide show
  1. package/agent/index.js +12 -0
  2. package/agent/lib/http.js +21 -3
  3. package/agent/lib/logger.js +15 -0
  4. package/agent/lib/provision-commands.js +176 -0
  5. package/agent/lib/sandbox.js +667 -118
  6. package/agent/lib/sdk.js +1 -20
  7. package/ai/skills/testdriver-find/SKILL.md +14 -20
  8. package/docs/_data/examples-manifest.json +46 -46
  9. package/docs/_scripts/extract-example-urls.js +67 -72
  10. package/docs/changelog.mdx +26 -0
  11. package/docs/docs.json +2 -1
  12. package/docs/v7/examples/ai.mdx +1 -1
  13. package/docs/v7/examples/assert.mdx +1 -1
  14. package/docs/v7/examples/captcha-api.mdx +1 -1
  15. package/docs/v7/examples/chrome-extension.mdx +1 -1
  16. package/docs/v7/examples/drag-and-drop.mdx +1 -1
  17. package/docs/v7/examples/element-not-found.mdx +1 -1
  18. package/docs/v7/examples/exec-output.mdx +1 -1
  19. package/docs/v7/examples/exec-pwsh.mdx +1 -1
  20. package/docs/v7/examples/focus-window.mdx +1 -1
  21. package/docs/v7/examples/hover-image.mdx +1 -1
  22. package/docs/v7/examples/hover-text.mdx +1 -1
  23. package/docs/v7/examples/installer.mdx +1 -1
  24. package/docs/v7/examples/launch-vscode-linux.mdx +1 -1
  25. package/docs/v7/examples/match-image.mdx +1 -1
  26. package/docs/v7/examples/press-keys.mdx +1 -1
  27. package/docs/v7/examples/scroll-keyboard.mdx +1 -1
  28. package/docs/v7/examples/scroll-until-image.mdx +1 -1
  29. package/docs/v7/examples/scroll-until-text.mdx +1 -1
  30. package/docs/v7/examples/scroll.mdx +1 -1
  31. package/docs/v7/examples/type.mdx +1 -1
  32. package/docs/v7/examples/windows-installer.mdx +1 -1
  33. package/docs/v7/find.mdx +14 -20
  34. package/docs/v7/test-results-json.mdx +258 -0
  35. package/examples/scroll-keyboard.test.mjs +1 -1
  36. package/examples/scroll.test.mjs +1 -12
  37. package/interfaces/vitest-plugin.mjs +167 -51
  38. package/lib/core/Dashcam.js +18 -31
  39. package/lib/environments.json +8 -4
  40. package/lib/github-comment.mjs +58 -40
  41. package/lib/init-project.js +5 -67
  42. package/lib/resolve-channel.js +39 -10
  43. package/lib/sentry.js +47 -23
  44. package/lib/vitest/hooks.mjs +117 -20
  45. package/manual/exec-stream-logs.test.mjs +25 -0
  46. package/mcp-server/dist/server.mjs +28 -8
  47. package/mcp-server/src/server.ts +31 -8
  48. package/package.json +2 -1
  49. package/sdk.d.ts +4 -0
  50. package/sdk.js +42 -12
  51. package/setup/aws/install-dev-runner.sh +79 -0
  52. package/setup/aws/spawn-runner.sh +165 -0
  53. package/test-sentry-span.js +35 -0
  54. package/vitest.config.mjs +7 -3
  55. package/vitest.runner.config.mjs +33 -0
  56. package/docs/v7/_drafts/core.mdx +0 -458
@@ -4,16 +4,14 @@ const { events } = require("../events");
4
4
  const logger = require("./logger");
5
5
  const { version } = require("../../package.json");
6
6
  const { withRetry, getSentryTraceHeaders } = require("./sdk");
7
+ const sentry = require("../../lib/sentry");
7
8
 
8
9
  const createSandbox = function (emitter, analytics, sessionInstance) {
9
10
  class Sandbox {
10
11
  constructor() {
11
12
  this._ably = null;
12
- this._cmdChannel = null;
13
- this._respChannel = null;
14
- this._ctrlChannel = null;
15
- this._filesChannel = null;
16
- this._channelNames = null;
13
+ this._sessionChannel = null;
14
+ this._channelName = null;
17
15
  this.ps = {};
18
16
  this._execBuffers = {}; // accumulate streamed exec.output chunks per requestId
19
17
  this.heartbeat = null;
@@ -31,6 +29,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
31
29
  this._lastConnectParams = null;
32
30
  this._teamId = null;
33
31
  this._sandboxId = null;
32
+ this._disconnectedAt = null; // tracks when Realtime connection dropped (for timeout extension on reconnect)
34
33
 
35
34
  // Rate limiting state for Ably publishes (Ably limits to 50 msg/sec per connection)
36
35
  this._publishLastTime = 0;
@@ -50,7 +49,11 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
50
49
  );
51
50
  }
52
51
 
53
- async _initAbly(ablyToken, channelNames) {
52
+ getPublishCount() {
53
+ return this._publishCount;
54
+ }
55
+
56
+ async _initAbly(ablyToken, channelName) {
54
57
  if (this._ably) {
55
58
  try {
56
59
  this._ably.close();
@@ -58,48 +61,69 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
58
61
  /* ignore */
59
62
  }
60
63
  }
61
- this._channelNames = channelNames;
64
+ this._channelName = channelName;
62
65
  var self = this;
63
66
 
64
67
  this._ably = new Ably.Realtime({
65
- authCallback: function (tokenParams, callback) {
66
- callback(null, ablyToken);
68
+ authCallback: async function (tokenParams, callback) {
69
+ // On initial connect Ably may supply the token directly; on renewal
70
+ // we must fetch a fresh one from the API (the original token will
71
+ // have expired, causing 40143 token.unrecognized if reused).
72
+ try {
73
+ const response = await axios({
74
+ method: "post",
75
+ url: self.apiRoot + "/api/v7/sandbox/ably-token",
76
+ data: { apiKey: self.apiKey, sandboxId: self._sandboxId },
77
+ headers: { "Content-Type": "application/json" },
78
+ timeout: 15000,
79
+ });
80
+ callback(null, response.data.token);
81
+ } catch (err) {
82
+ logger.warn("[ably] Token renewal failed, falling back to original token: " + (err.message || err));
83
+ callback(null, ablyToken);
84
+ }
67
85
  },
68
86
  clientId: "sdk-" + this._sandboxId,
87
+ echoMessages: false, // don't receive our own published messages
69
88
  disconnectedRetryTimeout: 5000, // retry reconnect every 5s (default 15s)
70
89
  suspendedRetryTimeout: 15000, // retry from suspended every 15s (default 30s)
71
90
  });
72
91
 
92
+ logger.debug(`[realtime] Connecting as sdk-${this._sandboxId}...`);
93
+
73
94
  await new Promise(function (resolve, reject) {
74
95
  self._ably.connection.on("connected", resolve);
75
96
  self._ably.connection.on("failed", function () {
76
- reject(new Error("Ably connection failed"));
97
+ reject(new Error("Realtime connection failed"));
77
98
  });
78
99
  setTimeout(function () {
79
- reject(new Error("Ably connection timeout"));
100
+ reject(new Error("Realtime connection timeout"));
80
101
  }, 30000);
81
102
  });
82
103
 
83
- this._cmdChannel = this._ably.channels.get(channelNames.commands);
84
- this._respChannel = this._ably.channels.get(channelNames.responses);
85
- this._ctrlChannel = this._ably.channels.get(channelNames.control);
86
- this._filesChannel = this._ably.channels.get(channelNames.files);
104
+ this._sessionChannel = this._ably.channels.get(channelName);
87
105
 
88
- // Enter presence on control channel so the API can count connected SDK clients
106
+ logger.debug(`[realtime] Channel initialized: ${channelName}`);
107
+
108
+ // Enter presence on the session channel so the API can count connected SDK clients
89
109
  try {
90
- await this._ctrlChannel.presence.enter({
110
+ await this._sessionChannel.presence.enter({
91
111
  sandboxId: this._sandboxId,
92
112
  connectedAt: Date.now(),
93
113
  });
114
+ logger.debug(`[realtime] Entered presence on session channel (sandbox=${this._sandboxId})`);
94
115
  } catch (e) {
95
116
  // Non-fatal — presence is used for concurrency counting, not critical path
96
- logger.warn("Failed to enter presence on control channel: " + (e.message || e));
117
+ logger.warn("Failed to enter presence on session channel: " + (e.message || e));
97
118
  }
98
119
 
99
- this._respChannel.subscribe("response", function (msg) {
120
+ // Save subscription references for historyBeforeSubscribe() during discontinuity recovery
121
+ this._onResponseMsg = function (msg) {
100
122
  var message = msg.data;
101
123
  if (!message) return;
102
124
 
125
+ logger.debug(`[realtime] Received response: type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
126
+
103
127
  if (message.type === "sandbox.progress") {
104
128
  emitter.emit(events.sandbox.progress, {
105
129
  step: message.step,
@@ -150,31 +174,53 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
150
174
  }
151
175
 
152
176
  if (!message.requestId || !self.ps[message.requestId]) {
153
- var debugMode =
154
- process.env.VERBOSE || process.env.TD_DEBUG;
155
- if (debugMode) {
156
- console.warn(
157
- "No pending promise found for requestId:",
158
- message.requestId,
159
- );
160
- }
177
+ var pendingIds = Object.keys(self.ps);
178
+ var pendingSummary = pendingIds.length > 0
179
+ ? pendingIds.map(function (rid) {
180
+ var e = self.ps[rid];
181
+ return rid + '(' + (e && e.message ? e.message.type : '?') + ')';
182
+ }).join(', ')
183
+ : 'none';
184
+ logger.debug(
185
+ '[realtime] No pending promise for requestId=' + (message.requestId || 'null') +
186
+ ' | response type=' + (message.type || 'unknown') +
187
+ ' | error=' + (message.error ? (message.errorMessage || 'true') : 'false') +
188
+ ' | currently pending: [' + pendingSummary + ']'
189
+ );
161
190
  return;
162
191
  }
163
192
 
164
193
  if (message.error) {
165
- var pendingMessage =
166
- self.ps[message.requestId] &&
167
- self.ps[message.requestId].message;
194
+ var pendingEntry = self.ps[message.requestId];
195
+ var pendingMessage = pendingEntry && pendingEntry.message;
196
+ var pendingAge = pendingEntry && pendingEntry.startTime
197
+ ? ((Date.now() - pendingEntry.startTime) / 1000).toFixed(1) + 's'
198
+ : '?';
199
+ logger.debug(
200
+ '[realtime] Promise REJECTED: requestId=' + message.requestId +
201
+ ' | type=' + (pendingMessage ? pendingMessage.type : 'unknown') +
202
+ ' | age=' + pendingAge +
203
+ ' | error=' + (message.errorMessage || 'Sandbox error')
204
+ );
168
205
  if (!pendingMessage || pendingMessage.type !== "output") {
169
206
  emitter.emit(events.error.sandbox, message.errorMessage);
170
207
  }
171
208
  var error = new Error(message.errorMessage || "Sandbox error");
172
209
  error.responseData = message;
173
210
  delete self._execBuffers[message.requestId];
174
- self.ps[message.requestId].reject(error);
211
+ pendingEntry.reject(error);
175
212
  } else {
176
213
  emitter.emit(events.sandbox.received);
177
214
  if (self.ps[message.requestId]) {
215
+ var resolveEntry = self.ps[message.requestId];
216
+ var resolveAge = resolveEntry.startTime
217
+ ? ((Date.now() - resolveEntry.startTime) / 1000).toFixed(1) + 's'
218
+ : '?';
219
+ logger.debug(
220
+ '[realtime] Promise RESOLVED: requestId=' + message.requestId +
221
+ ' | type=' + (resolveEntry.message ? resolveEntry.message.type : 'unknown') +
222
+ ' | age=' + resolveAge
223
+ );
178
224
  // Unwrap the result from the Ably response envelope
179
225
  // The runner sends { requestId, type, result, success }
180
226
  // But SDK commands expect just the result object
@@ -193,42 +239,156 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
193
239
  }
194
240
  }
195
241
  delete self.ps[message.requestId];
196
- });
242
+ };
243
+ this._responseSubscription = await this._sessionChannel.subscribe("response", this._onResponseMsg);
197
244
 
198
- this._filesChannel.subscribe("response", function (msg) {
245
+ this._onFileMsg = function (msg) {
199
246
  var message = msg.data;
200
247
  if (!message) return;
248
+ logger.debug(`[realtime] Received file: type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
201
249
  if (message.requestId && self.ps[message.requestId]) {
202
250
  emitter.emit(events.sandbox.received);
203
251
  self.ps[message.requestId].resolve(message);
204
252
  delete self.ps[message.requestId];
205
253
  }
206
254
  emitter.emit(events.sandbox.file, message);
207
- });
255
+ };
256
+ this._fileSubscription = await this._sessionChannel.subscribe("file", this._onFileMsg);
208
257
 
209
- this.heartbeat = setInterval(function () {}, 5000);
258
+ this.heartbeat = setInterval(function () { }, 5000);
210
259
  if (this.heartbeat.unref) this.heartbeat.unref();
211
260
 
261
+ // ─── Periodic stats logging ────────────────────────────────────────
262
+ this._statsInterval = setInterval(() => {
263
+ const connState = this._ably ? this._ably.connection.state : 'no-client';
264
+ const chState = this._sessionChannel ? this._sessionChannel.state : 'null';
265
+ const pendingIds = Object.keys(this.ps);
266
+ const pending = pendingIds.length;
267
+ logger.debug(`[realtime][stats] connection=${connState} | sandbox=${this._sandboxId} | pending=${pending} | channel=${chState}`);
268
+ if (pending > 0) {
269
+ const now = Date.now();
270
+ for (const rid of pendingIds) {
271
+ const entry = this.ps[rid];
272
+ if (!entry) continue;
273
+ const type = entry.message ? entry.message.type : 'unknown';
274
+ const ageSec = ((now - (entry.startTime || now)) / 1000).toFixed(1);
275
+ logger.debug(`[realtime][stats] pending: requestId=${rid} | type=${type} | age=${ageSec}s`);
276
+ }
277
+ }
278
+ }, 10000);
279
+ if (this._statsInterval.unref) this._statsInterval.unref();
280
+
212
281
  this._ably.connection.on("disconnected", function () {
213
- logger.log("Ably disconnected - will auto-reconnect");
282
+ logger.debug("[realtime] Connection: disconnected - will auto-reconnect");
283
+ self._disconnectedAt = Date.now();
214
284
  });
215
285
 
216
286
  this._ably.connection.on("connected", function () {
217
287
  // Log reconnection so the user knows the blip was recovered
218
- logger.log("Ably reconnected");
288
+ logger.debug("[realtime] Connection: reconnected");
289
+ // Extend any pending command timeouts by the disconnection duration so
290
+ // commands whose timer was counting down while the connection was down
291
+ // don't get incorrectly timed out.
292
+ if (self._disconnectedAt) {
293
+ var disconnectionDurationMs = Date.now() - self._disconnectedAt;
294
+ self._disconnectedAt = null;
295
+ var pendingIds = Object.keys(self.ps);
296
+ if (pendingIds.length > 0) {
297
+ logger.debug(
298
+ '[realtime] Extending ' + pendingIds.length + ' pending timeout(s) by ' +
299
+ disconnectionDurationMs + 'ms after disconnection'
300
+ );
301
+ for (var i = 0; i < pendingIds.length; i++) {
302
+ var entry = self.ps[pendingIds[i]];
303
+ if (entry && typeof entry.extendTimeout === 'function') {
304
+ entry.extendTimeout(disconnectionDurationMs);
305
+ }
306
+ }
307
+ }
308
+ }
219
309
  });
220
310
 
221
311
  this._ably.connection.on("suspended", function () {
222
- logger.warn("Ably suspended - connection lost for extended period, will keep retrying");
312
+ logger.debug("[realtime] Connection: suspended - connection lost for extended period, will keep retrying");
223
313
  });
224
314
 
225
315
  this._ably.connection.on("failed", function () {
316
+ logger.debug("[realtime] Connection: failed");
226
317
  self.apiSocketConnected = false;
227
318
  self.instanceSocketConnected = false;
228
- emitter.emit(events.error.sandbox, "Ably connection failed");
319
+ emitter.emit(events.error.sandbox, "Realtime connection failed");
320
+ });
321
+
322
+ // ─── Channel discontinuity detection ──────────────────────────────
323
+ // Set up BEFORE subscribing so we catch any continuity loss during
324
+ // the initial attachment. Fires at the channel level, covering all
325
+ // message types (response, file, control).
326
+ this._sessionChannel.on(function (stateChange) {
327
+ var current = stateChange.current;
328
+ var previous = stateChange.previous;
329
+ var reason = stateChange.reason;
330
+ var reasonMsg = reason ? (reason.message || reason.code || String(reason)) : '';
331
+
332
+ if (current === 'attached' && stateChange.resumed === false && previous === 'attached') {
333
+ logger.debug('[realtime] Channel DISCONTINUITY detected (resumed=false)' + (reasonMsg ? ' — ' + reasonMsg : ''));
334
+ emitter.emit(events.sandbox.progress, {
335
+ step: 'discontinuity',
336
+ message: 'Recovering missed messages after connection interruption...',
337
+ });
338
+ self._recoverFromDiscontinuity();
339
+ }
229
340
  });
230
341
  }
231
342
 
343
+ /**
344
+ * Recover missed messages after a channel discontinuity.
345
+ * Uses historyBeforeSubscribe() on each subscription, which guarantees
346
+ * no gap between historical and live messages. Each recovered message
347
+ * is dispatched through the same handler that processes live messages
348
+ * so that pending promises are resolved/rejected correctly.
349
+ */
350
+ async _recoverFromDiscontinuity() {
351
+ var subs = [
352
+ { name: 'response', sub: this._responseSubscription, handler: this._onResponseMsg },
353
+ { name: 'file', sub: this._fileSubscription, handler: this._onFileMsg },
354
+ ];
355
+ var totalRecovered = 0;
356
+ for (var i = 0; i < subs.length; i++) {
357
+ var entry = subs[i];
358
+ if (!entry.sub) continue;
359
+ try {
360
+ logger.debug('[realtime] Discontinuity recovery: fetching historyBeforeSubscribe for ' + entry.name + '...');
361
+ var page = await entry.sub.historyBeforeSubscribe({ limit: 100 });
362
+ var recovered = 0;
363
+ while (page) {
364
+ // Replay each missed message through the handler so pending
365
+ // promises get resolved instead of timing out.
366
+ for (var j = 0; j < page.items.length; j++) {
367
+ recovered++;
368
+ try {
369
+ if (entry.handler) {
370
+ logger.debug('[realtime] Replaying recovered ' + entry.name + ' message (requestId=' + (page.items[j].data && page.items[j].data.requestId || 'none') + ')');
371
+ entry.handler(page.items[j]);
372
+ }
373
+ } catch (replayErr) {
374
+ logger.debug('[realtime] Error replaying recovered message: ' + (replayErr.message || replayErr));
375
+ }
376
+ }
377
+ page = page.hasNext() ? await page.next() : null;
378
+ }
379
+ totalRecovered += recovered;
380
+ logger.debug('[realtime] Discontinuity recovery: replayed ' + recovered + ' ' + entry.name + ' message(s) from gap');
381
+ } catch (err) {
382
+ logger.debug('[realtime] Discontinuity recovery failed for ' + entry.name + ': ' + (err.message || err));
383
+ }
384
+ }
385
+ if (totalRecovered > 0) {
386
+ logger.debug('[realtime] Recovered and replayed ' + totalRecovered + ' message(s) that were missed during connection interruption');
387
+ } else {
388
+ logger.debug('[realtime] Discontinuity recovery: no missed messages found');
389
+ }
390
+ }
391
+
232
392
  /**
233
393
  * POST to the API with retry for transient network errors (via withRetry)
234
394
  * and infinite polling for CONCURRENCY_LIMIT_EXCEEDED (until vitest's
@@ -266,10 +426,10 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
266
426
  var elapsed = Date.now() - startTime;
267
427
  logger.warn(
268
428
  "Transient network error: " + (error.message || error.code) +
269
- " — POST " + path +
270
- " — retry " + attempt + "/3" +
271
- " in " + (delayMs / 1000).toFixed(1) + "s" +
272
- " (" + Math.round(elapsed / 1000) + "s elapsed)...",
429
+ " — POST " + path +
430
+ " — retry " + attempt + "/3" +
431
+ " in " + (delayMs / 1000).toFixed(1) + "s" +
432
+ " (" + Math.round(elapsed / 1000) + "s elapsed)...",
273
433
  );
274
434
  },
275
435
  });
@@ -281,10 +441,10 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
281
441
  var elapsed = Date.now() - startTime;
282
442
  logger.log(
283
443
  "Concurrency limit reached — waiting " +
284
- concurrencyRetryInterval / 1000 +
285
- "s for a slot to become available (" +
286
- Math.round(elapsed / 1000) +
287
- "s elapsed)...",
444
+ concurrencyRetryInterval / 1000 +
445
+ "s for a slot to become available (" +
446
+ Math.round(elapsed / 1000) +
447
+ "s elapsed)...",
288
448
  );
289
449
  await new Promise(function (resolve) {
290
450
  var t = setTimeout(resolve, concurrencyRetryInterval);
@@ -333,6 +493,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
333
493
  body.ci = message.ci;
334
494
  if (message.ami) body.ami = message.ami;
335
495
  if (message.instanceType) body.instanceType = message.instanceType;
496
+ if (message.e2bTemplateId) body.e2bTemplateId = message.e2bTemplateId;
336
497
  if (message.keepAlive !== undefined) body.keepAlive = message.keepAlive;
337
498
  }
338
499
 
@@ -361,62 +522,238 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
361
522
  this._teamId = reply.teamId;
362
523
 
363
524
  if (reply.ably && reply.ably.token) {
364
- await this._initAbly(reply.ably.token, reply.ably.channels);
525
+ await this._initAbly(reply.ably.token, reply.ably.channel);
365
526
  this.instanceSocketConnected = true;
366
527
 
367
528
  // Tell the runner to enable debug log forwarding if debug mode is on
368
529
  var debugMode =
369
530
  process.env.VERBOSE || process.env.TD_DEBUG;
370
- if (debugMode && this._ctrlChannel) {
371
- this._ctrlChannel.publish("control", {
531
+ if (debugMode && this._sessionChannel) {
532
+ this._sessionChannel.publish("control", {
372
533
  type: "debug",
373
534
  enabled: true,
374
535
  });
375
536
  }
376
537
  }
377
538
 
378
- if (message.type === "create") {
379
- // E2B (Linux) sandboxes: the API proxies commands and returns a url directly.
380
- // No runner agent involved skip runner.ready wait.
381
- if (reply.url) {
382
- logger.log(`E2B sandbox ready url=${reply.url}`);
383
- return {
384
- success: true,
385
- sandbox: {
386
- sandboxId: reply.sandboxId,
387
- instanceId: reply.sandbox?.sandboxId || reply.sandboxId,
388
- os: body.os || 'linux',
389
- url: reply.url,
390
- },
391
- };
539
+ // ─── Handle pending slot claim (trigger.dev waitpoint flow) ────
540
+ // The API returned early with status: 'pending'. The SDK has now
541
+ // connected to Ably and entered presence (done in _initAbly above).
542
+ // Wait for the claim-slot task to publish slot-approved or slot-denied
543
+ // on the control channel, then re-call authenticate with slotApproved.
544
+ // On slot-denied, we poll forever (re-calling authenticate every 10s)
545
+ // until a slot opens, matching _httpPostWithConcurrencyRetry behavior.
546
+ var concurrencyRetryInterval = 10000;
547
+ var slotPollStart = Date.now();
548
+ while (reply.status === 'pending') {
549
+ logger.log('Slot claim pending — waiting for approval via Ably...');
550
+
551
+ var self = this;
552
+ var slotResolved = false;
553
+ var slotResolve, slotReject;
554
+ var slotDecisionPromise = new Promise(function (resolve, reject) {
555
+ slotResolve = resolve;
556
+ slotReject = reject;
557
+ });
558
+
559
+ var slotTimeout = setTimeout(function () {
560
+ if (slotResolved) return;
561
+ slotResolved = true;
562
+ try { self._sessionChannel.unsubscribe('control', onSlotControl); } catch (_) {}
563
+ slotReject(new Error('Slot claim timed out waiting for approval'));
564
+ }, 60000); // 60s timeout
565
+ if (slotTimeout.unref) slotTimeout.unref();
566
+
567
+ function onSlotControl(msg) {
568
+ var data = msg.data;
569
+ if (!data) return;
570
+ if (data.type === 'slot-approved') {
571
+ if (slotResolved) return;
572
+ slotResolved = true;
573
+ clearTimeout(slotTimeout);
574
+ try { self._sessionChannel.unsubscribe('control', onSlotControl); } catch (_) {}
575
+ slotResolve({ approved: true, data: data });
576
+ } else if (data.type === 'slot-denied') {
577
+ if (slotResolved) return;
578
+ slotResolved = true;
579
+ clearTimeout(slotTimeout);
580
+ try { self._sessionChannel.unsubscribe('control', onSlotControl); } catch (_) {}
581
+ slotResolve({ approved: false, data: data });
582
+ }
583
+ }
584
+
585
+ // Subscribe FIRST, then check history to close the race window
586
+ // between presence enter (done in _initAbly) and this subscription.
587
+ // The claim-slot task fires in response to presence enter, so the
588
+ // decision may already be published by the time we get here.
589
+ var slotControlSub = await self._sessionChannel.subscribe('control', onSlotControl);
590
+
591
+ // Check for decisions published before this subscription was active
592
+ if (!slotResolved && slotControlSub) {
593
+ try {
594
+ var histPage = await slotControlSub.historyBeforeSubscribe({ limit: 10 });
595
+ while (histPage && !slotResolved) {
596
+ for (var hi = 0; hi < histPage.items.length; hi++) {
597
+ onSlotControl(histPage.items[hi]);
598
+ if (slotResolved) break;
599
+ }
600
+ histPage = (!slotResolved && histPage.hasNext()) ? await histPage.next() : null;
601
+ }
602
+ } catch (histErr) {
603
+ logger.warn('[slots] Failed to check history for slot decision: ' + (histErr.message || histErr));
604
+ }
605
+ }
606
+
607
+ var slotDecision = await slotDecisionPromise;
608
+
609
+ if (!slotDecision.approved) {
610
+ // Slot denied — disconnect Ably and re-try the full authenticate
611
+ // flow after a delay, polling forever until a slot opens.
612
+ var elapsed = Date.now() - slotPollStart;
613
+ logger.log(
614
+ 'Slot denied: ' + (slotDecision.data.message || 'concurrency limit reached') +
615
+ ' — waiting ' + (concurrencyRetryInterval / 1000) + 's before retrying' +
616
+ ' (' + Math.round(elapsed / 1000) + 's elapsed)...'
617
+ );
618
+ logger.log('Upgrade for more slots → https://console.testdriver.ai/checkout/team');
619
+ try {
620
+ if (this._ably) this._ably.close();
621
+ this._ably = null;
622
+ this._sessionChannel = null;
623
+ } catch (_) {}
624
+
625
+ await new Promise(function (resolve) {
626
+ var t = setTimeout(resolve, concurrencyRetryInterval);
627
+ if (t.unref) t.unref();
628
+ });
629
+
630
+ // Re-call authenticate — this goes through _httpPostWithConcurrencyRetry
631
+ // so transient HTTP errors are also handled. The new reply will either
632
+ // be 'pending' again (loop continues) or succeed directly.
633
+ reply = await this._httpPostWithConcurrencyRetry(
634
+ "/api/v7/sandbox/authenticate",
635
+ body,
636
+ timeout,
637
+ );
638
+
639
+ if (!reply.success && reply.status !== 'pending') {
640
+ var retryErr = new Error(
641
+ reply.errorMessage || "Failed to allocate sandbox",
642
+ );
643
+ retryErr.responseData = reply;
644
+ throw retryErr;
645
+ }
646
+
647
+ // Re-init Ably if we got a new pending reply with fresh credentials
648
+ if (reply.status === 'pending' && reply.ably && reply.ably.token) {
649
+ this._sandboxId = reply.sandboxId;
650
+ this._teamId = reply.teamId;
651
+ await this._initAbly(reply.ably.token, reply.ably.channel);
652
+ this.instanceSocketConnected = true;
653
+ }
654
+
655
+ continue; // loop back to wait for the next slot decision
656
+ }
657
+
658
+ logger.log('Slot approved — provisioning sandbox...');
659
+
660
+ // Re-call authenticate with slotApproved flag to trigger provisioning
661
+ // Keep the same sandboxId so the Ably channel stays valid
662
+ var provisionBody = {
663
+ apiKey: this.apiKey,
664
+ version: version,
665
+ os: message.os || this.os || 'linux',
666
+ session: sessionId,
667
+ apiRoot: this.apiRoot,
668
+ sandboxId: this._sandboxId,
669
+ slotApproved: true,
670
+ };
671
+ if (message.resolution) provisionBody.resolution = message.resolution;
672
+ if (message.ci) provisionBody.ci = message.ci;
673
+ if (message.ami) provisionBody.ami = message.ami;
674
+ if (message.instanceType) provisionBody.instanceType = message.instanceType;
675
+ if (message.e2bTemplateId) provisionBody.e2bTemplateId = message.e2bTemplateId;
676
+ if (message.keepAlive !== undefined) provisionBody.keepAlive = message.keepAlive;
677
+
678
+ reply = await this._httpPostWithConcurrencyRetry(
679
+ "/api/v7/sandbox/authenticate",
680
+ provisionBody,
681
+ timeout,
682
+ );
683
+
684
+ if (!reply.success) {
685
+ var provisionErr = new Error(
686
+ reply.errorMessage || "Failed to provision sandbox after approval",
687
+ );
688
+ provisionErr.responseData = reply;
689
+ throw provisionErr;
392
690
  }
393
691
 
692
+ break; // slot approved and provisioned — exit the while loop
693
+ }
694
+
695
+ if (message.type === "create") {
696
+ // E2B (Linux) sandboxes return a url directly.
697
+ // We still need to wait for runner.ready since sandbox-agent.js runs inside E2B.
698
+ const isE2B = !!reply.url;
699
+
394
700
  const runnerIp = reply.runner && reply.runner.ip;
395
701
  const noVncPort = reply.runner && reply.runner.noVncPort;
396
702
  const runnerVncUrl = reply.runner && reply.runner.vncUrl;
397
703
 
398
- logger.log(`Runner claimed ip=${runnerIp || 'none'}, os=${reply.runner?.os || 'unknown'}, noVncPort=${noVncPort || 'not reported'}, vncUrl=${runnerVncUrl || 'not reported'}`);
704
+ // Log image version info (AMI for Windows, E2B template for Linux)
705
+ if (reply.imageVersion) {
706
+ if (isE2B) {
707
+ logger.log('E2B image version: v' + reply.imageVersion + (reply.e2bTemplateId ? ' (template: ' + reply.e2bTemplateId + ')' : ''));
708
+ } else {
709
+ logger.log('AMI image version: v' + reply.imageVersion + (reply.amiId ? ' (ami: ' + reply.amiId + ')' : ''));
710
+ }
711
+ }
712
+
713
+ if (!isE2B) {
714
+ logger.log(`Runner claimed — ip=${runnerIp || 'none'}, os=${reply.runner?.os || 'unknown'}, noVncPort=${noVncPort || 'not reported'}, vncUrl=${runnerVncUrl || 'not reported'}`);
715
+ }
399
716
 
400
- // For cloud Windows sandboxes (no runner in reply), wait for the
401
- // agent to signal readiness before sending commands. Without this
402
- // gate, commands published before the agent subscribes are lost.
717
+ // Wait for the runner agent to signal readiness before sending commands.
718
+ // Without this gate, commands published before the agent subscribes are lost.
719
+ // This applies to:
720
+ // - E2B Linux sandboxes (native runner agent via sandbox-agent.js)
721
+ // - Windows EC2 sandboxes without presence runners
722
+ // For presence-based Windows runners (reply.runner already set), the runner
723
+ // is already listening so we can skip the wait.
403
724
  var self = this;
404
- if (!reply.runner && this._ctrlChannel) {
725
+ const needsReadyWait = this._sessionChannel && (isE2B || !reply.runner);
726
+ if (needsReadyWait) {
405
727
  logger.log('Waiting for runner agent to signal readiness...');
406
- var readyTimeout = 120000; // 120s allows for EC2 boot + agent startup
728
+ // E2B (Linux) sandboxes need extra time: S3 upload + npm install can add 60-120s on top of sandbox boot
729
+ // EC2 (Windows) cold starts can be slow due to AV scanning and native module loading
730
+ var readyTimeout = isE2B ? 300000 : 180000; // 5 min for E2B (S3+npm), 3 min for EC2
407
731
  await new Promise(function (resolve, reject) {
408
732
  var resolved = false;
733
+ var waitStart = Date.now();
409
734
  function finish(data) {
410
735
  if (resolved) return;
411
736
  resolved = true;
412
737
  clearTimeout(timer);
413
- self._ctrlChannel.unsubscribe('control', onCtrl);
738
+ clearInterval(progressTimer);
739
+ self._sessionChannel.unsubscribe('control', onCtrl);
414
740
  // Update runner info if provided
415
741
  if (data && data.os) reply.runner = reply.runner || {};
416
742
  if (data && data.os && reply.runner) reply.runner.os = data.os;
417
743
  if (data && data.ip && reply.runner) reply.runner.ip = data.ip;
418
744
  if (data && data.runnerVersion && reply.runner) reply.runner.version = data.runnerVersion;
745
+ // Persist version metadata for test result reporting
746
+ self._runnerVersionBefore = reply.imageVersion || null;
747
+ self._runnerVersionAfter = (data && data.runnerVersion) || reply.imageVersion || null;
748
+ self._wasUpdated = !!(data && data.runnerVersion && reply.imageVersion && data.runnerVersion !== reply.imageVersion);
419
749
  logger.log('Runner agent ready (os=' + ((data && data.os) || 'unknown') + ', runner v' + ((data && data.runnerVersion) || 'unknown') + ')');
750
+ // Show upgrade info: if the runner's npm version differs from the baked image version,
751
+ // the runner was upgraded during provisioning.
752
+ var runnerVer = data && data.runnerVersion;
753
+ var imageVer = reply.imageVersion;
754
+ if (runnerVer && imageVer && runnerVer !== imageVer) {
755
+ logger.log('Runner upgraded during provisioning: v' + imageVer + ' \u2192 v' + runnerVer);
756
+ }
420
757
  if (data && data.update) {
421
758
  var u = data.update;
422
759
  if (u.status === 'up-to-date') {
@@ -435,12 +772,25 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
435
772
  var timer = setTimeout(function () {
436
773
  if (!resolved) {
437
774
  resolved = true;
438
- self._ctrlChannel.unsubscribe('control', onCtrl);
439
- reject(new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms'));
775
+ clearInterval(progressTimer);
776
+ self._sessionChannel.unsubscribe('control', onCtrl);
777
+ var err = new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms');
778
+ sentry.captureException(err, {
779
+ tags: { phase: 'runner_ready', connection_type: 'create' },
780
+ extra: { readyTimeout: readyTimeout, sandboxId: reply.sandboxId },
781
+ });
782
+ reject(err);
440
783
  }
441
784
  }, readyTimeout);
442
785
  if (timer.unref) timer.unref();
443
786
 
787
+ // Log progress every 15s so the user knows we're still waiting
788
+ var progressTimer = setInterval(function () {
789
+ if (resolved) return;
790
+ var elapsed = Math.round((Date.now() - waitStart) / 1000);
791
+ logger.log('Still waiting for runner agent... (' + elapsed + 's elapsed, timeout=' + Math.round(readyTimeout / 1000) + 's)');
792
+ }, 15000);
793
+
444
794
  // Listen for live runner.ready messages
445
795
  var onCtrl;
446
796
  onCtrl = function (msg) {
@@ -449,12 +799,12 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
449
799
  finish(data);
450
800
  }
451
801
  };
452
- self._ctrlChannel.subscribe('control', onCtrl);
802
+ self._sessionChannel.subscribe('control', onCtrl);
453
803
 
454
804
  // Also check channel history in case runner.ready was published
455
805
  // before we subscribed (race condition on fast-booting agents).
456
806
  try {
457
- self._ctrlChannel.history({ limit: 50 }, function (err, page) {
807
+ self._sessionChannel.history({ limit: 50 }, function (err, page) {
458
808
  if (err) {
459
809
  logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
460
810
  return;
@@ -476,9 +826,13 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
476
826
  });
477
827
  }
478
828
  // Prefer the full vncUrl reported by the runner (infrastructure-agnostic).
829
+ // For E2B sandboxes, use the url from the API reply.
479
830
  // Fall back to constructing from ip + noVncPort for older runners.
480
831
  let url;
481
- if (runnerVncUrl) {
832
+ if (isE2B && reply.url) {
833
+ url = reply.url;
834
+ logger.log(`E2B sandbox ready — url=${url}`);
835
+ } else if (runnerVncUrl) {
482
836
  url = runnerVncUrl;
483
837
  logger.log(`Using runner-provided vncUrl: ${url}`);
484
838
  } else if (runnerIp && noVncPort) {
@@ -500,35 +854,57 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
500
854
  url: url,
501
855
  vncPort: noVncPort || undefined,
502
856
  runner: reply.runner,
857
+ // Extra metadata for test result reporting
858
+ amiId: reply.amiId || null,
859
+ e2bTemplateId: reply.e2bTemplateId || null,
860
+ imageVersion: reply.imageVersion || null,
861
+ runnerVersionBefore: this._runnerVersionBefore || reply.imageVersion || null,
862
+ runnerVersionAfter: this._runnerVersionAfter || reply.runner?.version || null,
863
+ wasUpdated: this._wasUpdated || false,
864
+ vncUrl: url || null,
865
+ channelName: this._channelName || null,
503
866
  },
504
867
  };
505
868
  }
506
869
 
507
870
  if (message.type === "direct") {
508
- // If the API returned agent config and we have an instanceId,
509
- // provision the config to the instance via SSM (client-side).
871
+ // If the API returned provisioning commands and we have an instanceId,
872
+ // send them to the instance via SSM (client-side).
510
873
  // This runs from the user's infrastructure where AWS permissions exist,
511
874
  // rather than from the API server.
512
- if (reply.agentConfig && message.instanceId) {
513
- logger.log('Provisioning agent config to instance ' + message.instanceId + ' via SSM...');
514
- await this._provisionAgentConfig(message.instanceId, reply.agentConfig);
875
+ // NOTE: For direct connections, the user MUST provide the AWS instanceId
876
+ // because the API only knows the sandboxId, not the actual EC2 instance ID.
877
+ var instanceId = message.instanceId;
878
+ if (instanceId && reply.provisionCommands) {
879
+ // New path: API generated full provisioning commands (runner install + config + start)
880
+ logger.log('Provisioning instance ' + instanceId + ' via SSM (API-generated commands)...');
881
+ await this._sendSSMCommands(instanceId, reply.provisionCommands);
882
+ logger.log('Instance provisioned successfully.');
883
+ } else if (reply.agentConfig && instanceId) {
884
+ // Fallback: older API that only returns agentConfig (config-only, no runner install)
885
+ logger.log('Provisioning agent config to instance ' + instanceId + ' via SSM (legacy)...');
886
+ await this._provisionAgentConfig(instanceId, reply.agentConfig);
515
887
  logger.log('Agent config provisioned successfully.');
888
+ } else if ((reply.agentConfig || reply.provisionCommands) && !instanceId) {
889
+ logger.log('Warning: agentConfig/provisionCommands returned but no instanceId provided - cannot provision via SSM');
516
890
  }
517
891
 
518
892
  // If the API returned agent credentials (reply.agent present),
519
893
  // wait for the runner agent to signal readiness before sending commands.
520
894
  // Without this gate, commands published before the agent subscribes are lost.
521
895
  var self = this;
522
- if (reply.agent && this._ctrlChannel) {
896
+ if (reply.agent && this._sessionChannel) {
523
897
  logger.log('Waiting for runner agent to signal readiness (direct connection)...');
524
- var readyTimeout = 120000; // 120s — allows for SSM provisioning + agent startup
898
+ var readyTimeout = 60000 * 5;
525
899
  await new Promise(function (resolve, reject) {
526
900
  var resolved = false;
901
+ var waitStart = Date.now();
527
902
  function finish(data) {
528
903
  if (resolved) return;
529
904
  resolved = true;
530
905
  clearTimeout(timer);
531
- self._ctrlChannel.unsubscribe('control', onCtrl);
906
+ clearInterval(progressTimer);
907
+ self._sessionChannel.unsubscribe('control', onCtrl);
532
908
  logger.log('Runner agent ready (direct, os=' + ((data && data.os) || 'unknown') + ', runner v' + ((data && data.runnerVersion) || 'unknown') + ')');
533
909
  if (data && data.update) {
534
910
  var u = data.update;
@@ -548,12 +924,25 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
548
924
  var timer = setTimeout(function () {
549
925
  if (!resolved) {
550
926
  resolved = true;
551
- self._ctrlChannel.unsubscribe('control', onCtrl);
552
- reject(new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms (direct connection)'));
927
+ clearInterval(progressTimer);
928
+ self._sessionChannel.unsubscribe('control', onCtrl);
929
+ var err = new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms (direct connection)');
930
+ sentry.captureException(err, {
931
+ tags: { phase: 'runner_ready', connection_type: 'direct' },
932
+ extra: { readyTimeout: readyTimeout, sandboxId: reply.sandboxId, instanceId: message.instanceId },
933
+ });
934
+ reject(err);
553
935
  }
554
936
  }, readyTimeout);
555
937
  if (timer.unref) timer.unref();
556
938
 
939
+ // Log progress every 15s so the user knows we're still waiting
940
+ var progressTimer = setInterval(function () {
941
+ if (resolved) return;
942
+ var elapsed = Math.round((Date.now() - waitStart) / 1000);
943
+ logger.log('Still waiting for runner agent... (' + elapsed + 's elapsed, timeout=' + Math.round(readyTimeout / 1000) + 's)');
944
+ }, 15000);
945
+
557
946
  // Listen for live runner.ready messages
558
947
  var onCtrl;
559
948
  onCtrl = function (msg) {
@@ -562,12 +951,12 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
562
951
  finish(data);
563
952
  }
564
953
  };
565
- self._ctrlChannel.subscribe('control', onCtrl);
954
+ self._sessionChannel.subscribe('control', onCtrl);
566
955
 
567
956
  // Also check channel history in case runner.ready was published
568
957
  // before we subscribed (race condition on fast-booting agents).
569
958
  try {
570
- self._ctrlChannel.history({ limit: 50 }, function (err, page) {
959
+ self._sessionChannel.history({ limit: 50 }, function (err, page) {
571
960
  if (err) {
572
961
  logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
573
962
  return;
@@ -609,7 +998,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
609
998
  _sendAbly(message, timeout) {
610
999
  if (timeout === undefined) timeout = 300000;
611
1000
 
612
- if (!this._cmdChannel || !this._ably) {
1001
+ if (!this._sessionChannel || !this._ably) {
613
1002
  return Promise.reject(
614
1003
  new Error("Sandbox not connected (no Ably client)"),
615
1004
  );
@@ -637,7 +1026,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
637
1026
  function onFailed() {
638
1027
  clearTimeout(timer);
639
1028
  self._ably.connection.off("connected", onConnected);
640
- reject(new Error("Ably connection failed while waiting to send"));
1029
+ reject(new Error("Realtime connection failed while waiting to send"));
641
1030
  }
642
1031
  self._ably.connection.once("connected", onConnected);
643
1032
  self._ably.connection.once("failed", onFailed);
@@ -695,21 +1084,41 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
695
1084
 
696
1085
  var requestId = message.requestId;
697
1086
 
698
- var timeoutId = setTimeout(function () {
1087
+ // timeoutId and timeoutExpiresAt are declared as vars so they can be
1088
+ // updated by extendTimeout() (closure mutation).
1089
+ var timeoutId;
1090
+ var timeoutExpiresAt;
1091
+
1092
+ var timeoutFn = function () {
699
1093
  if (self.ps[requestId]) {
1094
+ var pendingIds = Object.keys(self.ps);
1095
+ var pendingSummary = pendingIds.map(function (rid) {
1096
+ var e = self.ps[rid];
1097
+ var age = e && e.startTime ? ((Date.now() - e.startTime) / 1000).toFixed(1) + 's' : '?';
1098
+ return rid + '(' + (e && e.message ? e.message.type : '?') + ', ' + age + ')';
1099
+ }).join(', ');
1100
+ logger.error(
1101
+ '[realtime] Promise TIMEOUT: requestId=' + requestId +
1102
+ ' | type=' + message.type +
1103
+ ' | timeout=' + timeout + 'ms' +
1104
+ ' | all pending: [' + pendingSummary + ']'
1105
+ );
700
1106
  delete self.ps[requestId];
701
1107
  delete self._execBuffers[requestId];
702
1108
  rejectPromise(
703
1109
  new Error(
704
1110
  "Sandbox message '" +
705
- message.type +
706
- "' timed out after " +
707
- timeout +
708
- "ms",
1111
+ message.type +
1112
+ "' timed out after " +
1113
+ timeout +
1114
+ "ms",
709
1115
  ),
710
1116
  );
711
1117
  }
712
- }, timeout);
1118
+ };
1119
+
1120
+ timeoutId = setTimeout(timeoutFn, timeout);
1121
+ timeoutExpiresAt = Date.now() + timeout;
713
1122
  if (timeoutId.unref) timeoutId.unref();
714
1123
 
715
1124
  this.ps[requestId] = {
@@ -722,15 +1131,35 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
722
1131
  clearTimeout(timeoutId);
723
1132
  rejectPromise(error);
724
1133
  },
1134
+ /**
1135
+ * Extend the pending timeout by disconnectionDurationMs — called on Ably reconnect
1136
+ * to compensate for time spent disconnected.
1137
+ */
1138
+ extendTimeout: function (disconnectionDurationMs) {
1139
+ clearTimeout(timeoutId);
1140
+ // Clamp remaining to 0 so a command whose timer expired during the
1141
+ // outage still gets the full disconnection duration as its new budget.
1142
+ var remaining = Math.max(0, timeoutExpiresAt - Date.now());
1143
+ // Minimum 5s remaining after extension to allow the response to arrive.
1144
+ var MIN_REMAINING_MS = 5000;
1145
+ var newRemaining = Math.max(remaining + disconnectionDurationMs, MIN_REMAINING_MS);
1146
+ timeoutExpiresAt = Date.now() + newRemaining;
1147
+ timeoutId = setTimeout(timeoutFn, newRemaining);
1148
+ if (timeoutId.unref) timeoutId.unref();
1149
+ logger.log(
1150
+ '[realtime] Extended timeout for requestId=' + requestId +
1151
+ ' by ' + disconnectionDurationMs + 'ms (new remaining: ' + Math.round(newRemaining / 1000) + 's)'
1152
+ );
1153
+ },
725
1154
  message: message,
726
1155
  startTime: Date.now(),
727
1156
  };
728
1157
 
729
1158
  if (message.type === "output") {
730
- p.catch(function () {});
1159
+ p.catch(function () { });
731
1160
  }
732
1161
 
733
- this._throttledPublish(this._cmdChannel, "command", message)
1162
+ this._throttledPublish(this._sessionChannel, "command", message)
734
1163
  .then(function () {
735
1164
  emitter.emit(events.sandbox.sent, message);
736
1165
  })
@@ -789,7 +1218,9 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
789
1218
  this._publishWindowStart = Date.now();
790
1219
  }
791
1220
 
792
- return channel.publish(eventName, message);
1221
+ return channel.publish(eventName, message).then(function () {
1222
+ logger.debug(`[realtime] Published: channel=${channel.name.split(':').pop()}, event=${eventName}, type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
1223
+ });
793
1224
  }
794
1225
 
795
1226
  async auth(apiKey) {
@@ -818,7 +1249,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
818
1249
  logger.log("Trace Report (Share When Reporting Bugs):");
819
1250
  logger.log(
820
1251
  "https://testdriver.sentry.io/explore/traces/trace/" +
821
- reply.traceId,
1252
+ reply.traceId,
822
1253
  );
823
1254
  }
824
1255
 
@@ -861,7 +1292,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
861
1292
  this._sandboxId = reply.sandboxId;
862
1293
 
863
1294
  if (reply.ably && reply.ably.token) {
864
- await this._initAbly(reply.ably.token, reply.ably.channels);
1295
+ await this._initAbly(reply.ably.token, reply.ably.channel);
865
1296
  }
866
1297
 
867
1298
  this.setConnectionParams({
@@ -910,38 +1341,43 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
910
1341
  clearInterval(this.heartbeat);
911
1342
  this.heartbeat = null;
912
1343
  }
1344
+ if (this._statsInterval) {
1345
+ clearInterval(this._statsInterval);
1346
+ this._statsInterval = null;
1347
+ }
913
1348
 
914
1349
  // Send end-session control message to runner before disconnecting
915
- if (this._ctrlChannel && this._ably?.connection?.state === 'connected') {
1350
+ if (this._sessionChannel && this._ably?.connection?.state === 'connected') {
916
1351
  try {
917
- await this._ctrlChannel.publish('control', { type: 'end-session' });
1352
+ logger.debug('[realtime] Publishing control: type=end-session');
1353
+ await this._sessionChannel.publish('control', { type: 'end-session' });
918
1354
  } catch (e) {
919
1355
  // Ignore - best effort
920
1356
  }
921
1357
  }
922
1358
 
923
- // Leave presence on control channel
924
- if (this._ctrlChannel) {
1359
+ // Leave presence on session channel
1360
+ if (this._sessionChannel) {
925
1361
  try {
926
- await this._ctrlChannel.presence.leave();
1362
+ logger.debug('[realtime] Leaving presence on session channel');
1363
+ await this._sessionChannel.presence.leave();
927
1364
  } catch (e) {
928
1365
  // ignore - best effort, Ably will auto-leave on disconnect
929
1366
  }
930
1367
  }
931
1368
 
932
1369
  try {
933
- await Promise.allSettled([
934
- this._cmdChannel?.detach(),
935
- this._respChannel?.detach(),
936
- this._ctrlChannel?.detach(),
937
- this._filesChannel?.detach(),
938
- ].filter(Boolean));
1370
+ logger.debug('[realtime] Detaching session channel');
1371
+ if (this._sessionChannel) {
1372
+ await this._sessionChannel.detach();
1373
+ }
939
1374
  } catch (e) {
940
1375
  /* ignore */
941
1376
  }
942
1377
 
943
1378
  if (this._ably) {
944
1379
  try {
1380
+ logger.debug('[realtime] Closing Realtime connection');
945
1381
  this._ably.close();
946
1382
  } catch (e) {
947
1383
  /* ignore */
@@ -949,11 +1385,8 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
949
1385
  this._ably = null;
950
1386
  }
951
1387
 
952
- this._cmdChannel = null;
953
- this._respChannel = null;
954
- this._ctrlChannel = null;
955
- this._filesChannel = null;
956
- this._channelNames = null;
1388
+ this._sessionChannel = null;
1389
+ this._channelName = null;
957
1390
  this.apiSocketConnected = false;
958
1391
  this.instanceSocketConnected = false;
959
1392
  this.authenticated = false;
@@ -962,9 +1395,66 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
962
1395
  this.ps = {};
963
1396
  }
964
1397
 
1398
+ /**
1399
+ * Send pre-generated PowerShell commands to an EC2 instance via AWS SSM.
1400
+ * The commands are generated by the API (sdk/agent/lib/provision-commands.js)
1401
+ * so provisioning logic lives in one place.
1402
+ */
1403
+ async _sendSSMCommands(instanceId, commands) {
1404
+ const { execSync } = require('child_process');
1405
+ const { writeFileSync, unlinkSync } = require('fs');
1406
+ const { join } = require('path');
1407
+ const { tmpdir } = require('os');
1408
+ const { randomUUID } = require('crypto');
1409
+
1410
+ const region = process.env.AWS_REGION || 'us-east-2';
1411
+ const paramsJson = JSON.stringify({ commands: commands });
1412
+ const tmpFile = join(tmpdir(), 'td-provision-' + randomUUID() + '.json');
1413
+ writeFileSync(tmpFile, paramsJson);
1414
+
1415
+ try {
1416
+ const output = execSync(
1417
+ 'aws ssm send-command --region "' + region + '" --instance-ids "' + instanceId + '" ' +
1418
+ '--document-name "AWS-RunPowerShellScript" ' +
1419
+ '--parameters file://' + tmpFile + ' --output json',
1420
+ { encoding: 'utf-8', timeout: 30000 }
1421
+ );
1422
+ var cmdId = JSON.parse(output).Command.CommandId;
1423
+ logger.log('SSM command sent: ' + cmdId);
1424
+
1425
+ // Wait for the command to complete
1426
+ execSync(
1427
+ 'aws ssm wait command-executed --region "' + region + '" ' +
1428
+ '--command-id "' + cmdId + '" --instance-id "' + instanceId + '"',
1429
+ { encoding: 'utf-8', timeout: 300000 } // 5 min — runner install can take a while
1430
+ );
1431
+
1432
+ // Get the command output for debugging
1433
+ try {
1434
+ var invocationOutput = execSync(
1435
+ 'aws ssm get-command-invocation --region "' + region + '" ' +
1436
+ '--command-id "' + cmdId + '" --instance-id "' + instanceId + '" --output json',
1437
+ { encoding: 'utf-8', timeout: 30000 }
1438
+ );
1439
+ var invocation = JSON.parse(invocationOutput);
1440
+ if (invocation.StandardOutputContent) {
1441
+ logger.log('SSM output:\n' + invocation.StandardOutputContent);
1442
+ }
1443
+ if (invocation.StandardErrorContent) {
1444
+ logger.warn('SSM errors:\n' + invocation.StandardErrorContent);
1445
+ }
1446
+ } catch (e) {
1447
+ logger.warn('Could not retrieve SSM command output: ' + e.message);
1448
+ }
1449
+ } finally {
1450
+ try { unlinkSync(tmpFile); } catch (e) { /* ignore */ }
1451
+ }
1452
+ }
1453
+
965
1454
  /**
966
1455
  * Write the agent config JSON to an EC2 instance via AWS SSM.
967
1456
  * Runs client-side so the API doesn't need AWS permissions on user infra.
1457
+ * LEGACY: Used when connecting to an API that doesn't return provisionCommands.
968
1458
  */
969
1459
  async _provisionAgentConfig(instanceId, agentConfig) {
970
1460
  const { execSync } = require('child_process');
@@ -976,14 +1466,55 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
976
1466
  const region = process.env.AWS_REGION || 'us-east-2';
977
1467
 
978
1468
  // Write SSM parameters to a temp file to avoid shell quoting issues
1469
+ // Log key config details for debugging
1470
+ logger.log('Agent config being provisioned:');
1471
+ logger.log(' sandboxId: ' + agentConfig.sandboxId);
1472
+ logger.log(' apiRoot: ' + agentConfig.apiRoot);
1473
+ logger.log(' channel: ' + (agentConfig.ably?.channel || 'N/A'));
1474
+ logger.log(' token length: ' + (agentConfig.ably?.token ? JSON.stringify(agentConfig.ably.token).length : 0));
1475
+
979
1476
  const paramsJson = JSON.stringify({
980
1477
  commands: [
1478
+ // Debug: show existing state
1479
+ "Write-Host '=== Checking existing state ==='",
1480
+ "$task = Get-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction SilentlyContinue",
1481
+ "if ($task) { Write-Host \"Task exists, state: $($task.State)\" } else { Write-Host 'Task does NOT exist!' }",
1482
+ "if (Test-Path 'C:\\Windows\\Temp\\testdriver-agent.json') { Write-Host 'Old config:'; Get-Content 'C:\\Windows\\Temp\\testdriver-agent.json' | Write-Host } else { Write-Host 'Config file does NOT exist yet' }",
1483
+ // Stop any running runner
1484
+ "Write-Host '=== Stopping runner ==='",
1485
+ "Stop-Process -Name node -Force -ErrorAction SilentlyContinue",
1486
+ "Stop-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction SilentlyContinue",
1487
+ // Write config
1488
+ "Write-Host '=== Writing config ==='",
981
1489
  "$config = '" + configJson.replace(/'/g, "''") + "'",
982
1490
  "[System.IO.File]::WriteAllText('C:\\Windows\\Temp\\testdriver-agent.json', $config)",
983
1491
  "Write-Host 'Config written for sandbox " + agentConfig.sandboxId + "'",
1492
+ // Show what was written (redact token)
1493
+ "Write-Host '=== New config (token redacted) ==='",
1494
+ "$cfg = Get-Content 'C:\\Windows\\Temp\\testdriver-agent.json' | ConvertFrom-Json",
1495
+ "Write-Host \"sandboxId: $($cfg.sandboxId)\"",
1496
+ "Write-Host \"apiRoot: $($cfg.apiRoot)\"",
1497
+ "Write-Host \"channel: $($cfg.ably.channel)\"",
1498
+ "Write-Host \"token type: $($cfg.ably.token.GetType().Name)\"",
1499
+ // Start the runner
1500
+ "Write-Host '=== Starting runner ==='",
1501
+ "Start-Sleep -Seconds 1",
1502
+ "Start-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction Stop",
1503
+ "$task = Get-ScheduledTask -TaskName RunTestDriverAgent",
1504
+ "Write-Host \"Task state after start: $($task.State)\"",
1505
+ // Check if node process started
1506
+ "Start-Sleep -Seconds 3",
1507
+ "Write-Host '=== Checking runner process ==='",
1508
+ "$procs = Get-Process -Name node -ErrorAction SilentlyContinue",
1509
+ "if ($procs) { Write-Host \"Node processes: $($procs.Count)\"; $procs | ForEach-Object { Write-Host \" PID: $($_.Id), StartTime: $($_.StartTime)\" } } else { Write-Host 'No node process found!' }",
1510
+ // Check runner logs
1511
+ "Write-Host '=== Runner log (last 30 lines) ==='",
1512
+ "if (Test-Path 'C:\\testdriver\\logs\\sandbox-agent.log') { Get-Content 'C:\\testdriver\\logs\\sandbox-agent.log' -Tail 30 | Write-Host } else { Write-Host 'No log file found' }",
1513
+ "Write-Host '=== Done ==='",
984
1514
  ],
985
1515
  });
986
- const tmpFile = join(tmpdir(), 'td-provision-' + Date.now() + '.json');
1516
+ const { randomUUID } = require('crypto');
1517
+ const tmpFile = join(tmpdir(), 'td-provision-' + randomUUID() + '.json');
987
1518
  writeFileSync(tmpFile, paramsJson);
988
1519
 
989
1520
  try {
@@ -1002,6 +1533,24 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
1002
1533
  '--command-id "' + cmdId + '" --instance-id "' + instanceId + '"',
1003
1534
  { encoding: 'utf-8', timeout: 60000 }
1004
1535
  );
1536
+
1537
+ // Get the command output for debugging
1538
+ try {
1539
+ const invocationOutput = execSync(
1540
+ 'aws ssm get-command-invocation --region "' + region + '" ' +
1541
+ '--command-id "' + cmdId + '" --instance-id "' + instanceId + '" --output json',
1542
+ { encoding: 'utf-8', timeout: 30000 }
1543
+ );
1544
+ const invocation = JSON.parse(invocationOutput);
1545
+ if (invocation.StandardOutputContent) {
1546
+ logger.log('SSM output:\n' + invocation.StandardOutputContent);
1547
+ }
1548
+ if (invocation.StandardErrorContent) {
1549
+ logger.warn('SSM errors:\n' + invocation.StandardErrorContent);
1550
+ }
1551
+ } catch (e) {
1552
+ logger.warn('Could not retrieve SSM command output: ' + e.message);
1553
+ }
1005
1554
  } finally {
1006
1555
  try { unlinkSync(tmpFile); } catch (e) { /* ignore */ }
1007
1556
  }