testdriverai 7.4.4 → 7.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/copilot-instructions.md +1 -1
- package/.github/skills/testdriver:performing-actions/SKILL.md +3 -0
- package/.github/skills/testdriver:waiting-for-elements/SKILL.md +16 -0
- package/CHANGELOG.md +4 -0
- package/agent/events.js +7 -0
- package/agent/index.js +24 -17
- package/agent/lib/sandbox.js +703 -428
- package/agent/lib/system.js +70 -1
- package/ai/agents/testdriver.md +1 -1
- package/ai/skills/testdriver:performing-actions/SKILL.md +3 -0
- package/ai/skills/testdriver:testdriver/SKILL.md +1 -1
- package/ai/skills/testdriver:waiting-for-elements/SKILL.md +16 -0
- package/docs/_data/examples-manifest.json +68 -68
- package/docs/guide/best-practices-polling.mdx +25 -5
- package/docs/v7/examples/ai.mdx +1 -1
- package/docs/v7/examples/assert.mdx +1 -1
- package/docs/v7/examples/chrome-extension.mdx +1 -1
- package/docs/v7/examples/drag-and-drop.mdx +1 -1
- package/docs/v7/examples/element-not-found.mdx +1 -1
- package/docs/v7/examples/hover-image.mdx +1 -1
- package/docs/v7/examples/hover-text.mdx +1 -1
- package/docs/v7/examples/installer.mdx +1 -1
- package/docs/v7/examples/launch-vscode-linux.mdx +1 -1
- package/docs/v7/examples/match-image.mdx +1 -1
- package/docs/v7/examples/press-keys.mdx +1 -1
- package/docs/v7/examples/scroll-keyboard.mdx +1 -1
- package/docs/v7/examples/scroll-until-image.mdx +1 -1
- package/docs/v7/examples/scroll-until-text.mdx +1 -1
- package/docs/v7/examples/scroll.mdx +1 -1
- package/docs/v7/examples/type.mdx +1 -1
- package/docs/v7/examples/windows-installer.mdx +1 -1
- package/docs/v7/performing-actions.mdx +3 -0
- package/docs/v7/wait.mdx +52 -0
- package/docs/v7/waiting-for-elements.mdx +18 -0
- package/examples/chrome-extension.test.mjs +2 -0
- package/examples/no-provision.test.mjs +2 -0
- package/lib/vitest/hooks.mjs +9 -0
- package/lib/vitest/setup-aws.mjs +1 -0
- package/manual/packer-hover-image.test.mjs +176 -0
- package/package.json +2 -1
- package/sdk.d.ts +14 -2
- package/sdk.js +10 -0
- package/setup/aws/cloudformation.yaml +1 -8
- package/setup/aws/spawn-runner.sh +2 -37
- package/vitest.config.mjs +1 -1
package/agent/lib/sandbox.js
CHANGED
|
@@ -1,32 +1,93 @@
|
|
|
1
|
-
const WebSocket = require("ws");
|
|
2
1
|
const crypto = require("crypto");
|
|
2
|
+
const Ably = require("ably");
|
|
3
3
|
const { events } = require("../events");
|
|
4
4
|
const logger = require("./logger");
|
|
5
5
|
const { version } = require("../../package.json");
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
* Generate Sentry trace headers for distributed tracing
|
|
9
|
-
* Uses the same trace ID derivation as the API (MD5 hash of session ID)
|
|
10
|
-
* @param {string} sessionId - The session ID
|
|
11
|
-
* @returns {Object} Headers object with sentry-trace and baggage
|
|
12
|
-
*/
|
|
13
7
|
function getSentryTraceHeaders(sessionId) {
|
|
14
8
|
if (!sessionId) return {};
|
|
15
|
-
|
|
16
|
-
// Same logic as API: derive trace ID from session ID
|
|
17
9
|
const traceId = crypto.createHash("md5").update(sessionId).digest("hex");
|
|
18
10
|
const spanId = crypto.randomBytes(8).toString("hex");
|
|
19
|
-
|
|
20
11
|
return {
|
|
21
|
-
"sentry-trace":
|
|
22
|
-
baggage:
|
|
12
|
+
"sentry-trace": traceId + "-" + spanId + "-1",
|
|
13
|
+
baggage:
|
|
14
|
+
"sentry-trace_id=" +
|
|
15
|
+
traceId +
|
|
16
|
+
",sentry-sample_rate=1.0,sentry-sampled=true",
|
|
23
17
|
};
|
|
24
18
|
}
|
|
25
19
|
|
|
26
|
-
|
|
20
|
+
function httpPost(apiRoot, path, body, timeout) {
|
|
21
|
+
const http = require("http");
|
|
22
|
+
const https = require("https");
|
|
23
|
+
const url = new URL(apiRoot + path);
|
|
24
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
25
|
+
const bodyStr = JSON.stringify(body);
|
|
26
|
+
|
|
27
|
+
return new Promise(function (resolve, reject) {
|
|
28
|
+
var timeoutId = timeout
|
|
29
|
+
? setTimeout(function () {
|
|
30
|
+
req.destroy();
|
|
31
|
+
reject(
|
|
32
|
+
new Error("HTTP request timed out after " + timeout + "ms"),
|
|
33
|
+
);
|
|
34
|
+
}, timeout)
|
|
35
|
+
: null;
|
|
36
|
+
|
|
37
|
+
var req = transport.request(
|
|
38
|
+
url,
|
|
39
|
+
{
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: {
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
"Content-Length": Buffer.byteLength(bodyStr),
|
|
44
|
+
"Connection": "close",
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
function (res) {
|
|
48
|
+
var data = "";
|
|
49
|
+
res.on("data", function (chunk) {
|
|
50
|
+
data += chunk;
|
|
51
|
+
});
|
|
52
|
+
res.on("end", function () {
|
|
53
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
54
|
+
try {
|
|
55
|
+
var parsed = JSON.parse(data);
|
|
56
|
+
if (res.statusCode >= 400) {
|
|
57
|
+
var err = new Error(
|
|
58
|
+
parsed.errorMessage ||
|
|
59
|
+
parsed.message ||
|
|
60
|
+
"HTTP " + res.statusCode,
|
|
61
|
+
);
|
|
62
|
+
err.responseData = parsed;
|
|
63
|
+
reject(err);
|
|
64
|
+
} else {
|
|
65
|
+
resolve(parsed);
|
|
66
|
+
}
|
|
67
|
+
} catch (e) {
|
|
68
|
+
reject(new Error("Failed to parse API response: " + data));
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
req.on("error", function (err) {
|
|
74
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
75
|
+
reject(err);
|
|
76
|
+
});
|
|
77
|
+
req.write(bodyStr);
|
|
78
|
+
req.end();
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
27
83
|
class Sandbox {
|
|
28
84
|
constructor() {
|
|
29
|
-
this.
|
|
85
|
+
this._ably = null;
|
|
86
|
+
this._cmdChannel = null;
|
|
87
|
+
this._respChannel = null;
|
|
88
|
+
this._ctrlChannel = null;
|
|
89
|
+
this._filesChannel = null;
|
|
90
|
+
this._channelNames = null;
|
|
30
91
|
this.ps = {};
|
|
31
92
|
this.heartbeat = null;
|
|
32
93
|
this.apiSocketConnected = false;
|
|
@@ -35,525 +96,739 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
|
|
|
35
96
|
this.instance = null;
|
|
36
97
|
this.messageId = 0;
|
|
37
98
|
this.uniqueId = Math.random().toString(36).substring(7);
|
|
38
|
-
this.os = null;
|
|
39
|
-
this.sessionInstance = sessionInstance;
|
|
40
|
-
this.traceId = null;
|
|
41
|
-
this.reconnectAttempts = 0;
|
|
42
|
-
this.maxReconnectAttempts = 10;
|
|
43
|
-
this.intentionalDisconnect = false;
|
|
99
|
+
this.os = null;
|
|
100
|
+
this.sessionInstance = sessionInstance;
|
|
101
|
+
this.traceId = null;
|
|
44
102
|
this.apiRoot = null;
|
|
45
103
|
this.apiKey = null;
|
|
46
|
-
this.
|
|
47
|
-
this.
|
|
48
|
-
this.
|
|
49
|
-
this.pendingRetryQueue = []; // Queue of requests to retry after reconnection
|
|
50
|
-
this._lastConnectParams = null; // Connection params for reconnection (per-instance, not shared)
|
|
104
|
+
this._lastConnectParams = null;
|
|
105
|
+
this._teamId = null;
|
|
106
|
+
this._sandboxId = null;
|
|
51
107
|
}
|
|
52
108
|
|
|
53
|
-
/**
|
|
54
|
-
* Get the Sentry trace ID for this session
|
|
55
|
-
* Useful for debugging with customers - they can share this ID to look up their traces
|
|
56
|
-
* @returns {string|null} The trace ID or null if not authenticated
|
|
57
|
-
*/
|
|
58
109
|
getTraceId() {
|
|
59
110
|
return this.traceId;
|
|
60
111
|
}
|
|
61
112
|
|
|
62
|
-
/**
|
|
63
|
-
* Get the Sentry trace URL for this session
|
|
64
|
-
* @returns {string|null} The full Sentry trace URL or null if no trace ID
|
|
65
|
-
*/
|
|
66
113
|
getTraceUrl() {
|
|
67
114
|
if (!this.traceId) return null;
|
|
68
|
-
return
|
|
115
|
+
return (
|
|
116
|
+
"https://testdriver.sentry.io/explore/traces/trace/" + this.traceId
|
|
117
|
+
);
|
|
69
118
|
}
|
|
70
119
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
120
|
+
async _initAbly(ablyToken, channelNames) {
|
|
121
|
+
if (this._ably) {
|
|
122
|
+
try {
|
|
123
|
+
this._ably.close();
|
|
124
|
+
} catch (e) {
|
|
125
|
+
/* ignore */
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
this._channelNames = channelNames;
|
|
129
|
+
var self = this;
|
|
130
|
+
|
|
131
|
+
this._ably = new Ably.Realtime({
|
|
132
|
+
authCallback: function (tokenParams, callback) {
|
|
133
|
+
callback(null, ablyToken);
|
|
134
|
+
},
|
|
135
|
+
clientId: "sdk-" + this._sandboxId,
|
|
136
|
+
disconnectedRetryTimeout: 5000, // retry reconnect every 5s (default 15s)
|
|
137
|
+
suspendedRetryTimeout: 15000, // retry from suspended every 15s (default 30s)
|
|
138
|
+
});
|
|
74
139
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
140
|
+
await new Promise(function (resolve, reject) {
|
|
141
|
+
self._ably.connection.on("connected", resolve);
|
|
142
|
+
self._ably.connection.on("failed", function () {
|
|
143
|
+
reject(new Error("Ably connection failed"));
|
|
144
|
+
});
|
|
145
|
+
setTimeout(function () {
|
|
146
|
+
reject(new Error("Ably connection timeout"));
|
|
147
|
+
}, 30000);
|
|
148
|
+
});
|
|
80
149
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
150
|
+
this._cmdChannel = this._ably.channels.get(channelNames.commands);
|
|
151
|
+
this._respChannel = this._ably.channels.get(channelNames.responses);
|
|
152
|
+
this._ctrlChannel = this._ably.channels.get(channelNames.control);
|
|
153
|
+
this._filesChannel = this._ably.channels.get(channelNames.files);
|
|
154
|
+
|
|
155
|
+
this._respChannel.subscribe("response", function (msg) {
|
|
156
|
+
var message = msg.data;
|
|
157
|
+
if (!message) return;
|
|
158
|
+
|
|
159
|
+
if (message.type === "sandbox.progress") {
|
|
160
|
+
emitter.emit(events.sandbox.progress, {
|
|
161
|
+
step: message.step,
|
|
162
|
+
message: message.message,
|
|
163
|
+
});
|
|
164
|
+
return;
|
|
84
165
|
}
|
|
85
166
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
message.
|
|
167
|
+
if (
|
|
168
|
+
message.type === "before.file" ||
|
|
169
|
+
message.type === "after.file" ||
|
|
170
|
+
message.type === "screenshot.file"
|
|
171
|
+
) {
|
|
172
|
+
emitter.emit(events.sandbox.file, message);
|
|
173
|
+
return;
|
|
89
174
|
}
|
|
90
175
|
|
|
91
|
-
//
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
message.session = sessionId;
|
|
96
|
-
}
|
|
176
|
+
// Streaming exec output chunks — emit as events, don't resolve the pending promise
|
|
177
|
+
if (message.type === "exec.output") {
|
|
178
|
+
emitter.emit(events.exec.output, { chunk: message.chunk, requestId: message.requestId });
|
|
179
|
+
return;
|
|
97
180
|
}
|
|
98
181
|
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
182
|
+
// Runner debug logs — only received when debug mode is enabled
|
|
183
|
+
if (message.type === "runner.log") {
|
|
184
|
+
var logLevel = message.level || "info";
|
|
185
|
+
var logMsg = "[runner] " + (message.message || "");
|
|
186
|
+
if (logLevel === "error") {
|
|
187
|
+
logger.error(logMsg);
|
|
188
|
+
} else {
|
|
189
|
+
logger.log(logMsg);
|
|
107
190
|
}
|
|
191
|
+
emitter.emit(events.runner.log, {
|
|
192
|
+
level: logLevel,
|
|
193
|
+
message: message.message,
|
|
194
|
+
timestamp: message.timestamp,
|
|
195
|
+
});
|
|
196
|
+
return;
|
|
108
197
|
}
|
|
109
198
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const requestId = message.requestId;
|
|
118
|
-
|
|
119
|
-
// Set up timeout to prevent hanging requests
|
|
120
|
-
const timeoutId = setTimeout(() => {
|
|
121
|
-
this.pendingTimeouts.delete(requestId);
|
|
122
|
-
if (this.ps[requestId]) {
|
|
123
|
-
delete this.ps[requestId];
|
|
124
|
-
rejectPromise(
|
|
125
|
-
new Error(
|
|
126
|
-
`Sandbox message '${message.type}' timed out after ${timeout}ms`,
|
|
127
|
-
),
|
|
199
|
+
if (!message.requestId || !self.ps[message.requestId]) {
|
|
200
|
+
var debugMode =
|
|
201
|
+
process.env.VERBOSE || process.env.DEBUG || process.env.TD_DEBUG;
|
|
202
|
+
if (debugMode) {
|
|
203
|
+
console.warn(
|
|
204
|
+
"No pending promise found for requestId:",
|
|
205
|
+
message.requestId,
|
|
128
206
|
);
|
|
129
207
|
}
|
|
130
|
-
|
|
131
|
-
// Don't let pending timeouts prevent Node process from exiting
|
|
132
|
-
if (timeoutId.unref) {
|
|
133
|
-
timeoutId.unref();
|
|
208
|
+
return;
|
|
134
209
|
}
|
|
135
210
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
211
|
+
if (message.error) {
|
|
212
|
+
var pendingMessage =
|
|
213
|
+
self.ps[message.requestId] &&
|
|
214
|
+
self.ps[message.requestId].message;
|
|
215
|
+
if (!pendingMessage || pendingMessage.type !== "output") {
|
|
216
|
+
emitter.emit(events.error.sandbox, message.errorMessage);
|
|
217
|
+
}
|
|
218
|
+
var error = new Error(message.errorMessage || "Sandbox error");
|
|
219
|
+
error.responseData = message;
|
|
220
|
+
self.ps[message.requestId].reject(error);
|
|
221
|
+
} else {
|
|
222
|
+
emitter.emit(events.sandbox.received);
|
|
223
|
+
if (self.ps[message.requestId]) {
|
|
224
|
+
// Unwrap the result from the Ably response envelope
|
|
225
|
+
// The runner sends { requestId, type, result, success }
|
|
226
|
+
// But SDK commands expect just the result object
|
|
227
|
+
var resolvedValue = message.result !== undefined ? message.result : message;
|
|
228
|
+
self.ps[message.requestId].resolve(resolvedValue);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
delete self.ps[message.requestId];
|
|
232
|
+
});
|
|
154
233
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if (
|
|
159
|
-
|
|
234
|
+
this._filesChannel.subscribe("response", function (msg) {
|
|
235
|
+
var message = msg.data;
|
|
236
|
+
if (!message) return;
|
|
237
|
+
if (message.requestId && self.ps[message.requestId]) {
|
|
238
|
+
emitter.emit(events.sandbox.received);
|
|
239
|
+
self.ps[message.requestId].resolve(message);
|
|
240
|
+
delete self.ps[message.requestId];
|
|
160
241
|
}
|
|
242
|
+
emitter.emit(events.sandbox.file, message);
|
|
243
|
+
});
|
|
161
244
|
|
|
162
|
-
|
|
163
|
-
|
|
245
|
+
this.heartbeat = setInterval(function () {}, 5000);
|
|
246
|
+
if (this.heartbeat.unref) this.heartbeat.unref();
|
|
164
247
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const stateMap = {
|
|
169
|
-
[WebSocket.CONNECTING]: "connecting",
|
|
170
|
-
[WebSocket.CLOSING]: "closing",
|
|
171
|
-
[WebSocket.CLOSED]: "closed",
|
|
172
|
-
};
|
|
173
|
-
const stateDesc = stateMap[state] || "unavailable";
|
|
174
|
-
return Promise.reject(new Error(`Sandbox socket not connected (state: ${stateDesc})`));
|
|
175
|
-
}
|
|
248
|
+
this._ably.connection.on("disconnected", function () {
|
|
249
|
+
logger.log("Ably disconnected - will auto-reconnect");
|
|
250
|
+
});
|
|
176
251
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
type: "authenticate",
|
|
181
|
-
apiKey,
|
|
182
|
-
version,
|
|
252
|
+
this._ably.connection.on("connected", function () {
|
|
253
|
+
// Log reconnection so the user knows the blip was recovered
|
|
254
|
+
logger.log("Ably reconnected");
|
|
183
255
|
});
|
|
184
256
|
|
|
185
|
-
|
|
186
|
-
|
|
257
|
+
this._ably.connection.on("suspended", function () {
|
|
258
|
+
logger.warn("Ably suspended - connection lost for extended period, will keep retrying");
|
|
259
|
+
});
|
|
187
260
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
261
|
+
this._ably.connection.on("failed", function () {
|
|
262
|
+
self.apiSocketConnected = false;
|
|
263
|
+
self.instanceSocketConnected = false;
|
|
264
|
+
emitter.emit(events.error.sandbox, "Ably connection failed");
|
|
265
|
+
});
|
|
266
|
+
}
|
|
195
267
|
|
|
196
|
-
|
|
197
|
-
|
|
268
|
+
send(message, timeout) {
|
|
269
|
+
if (timeout === undefined) timeout = 300000;
|
|
270
|
+
if (message.type === "create" || message.type === "direct") {
|
|
271
|
+
return this._sendHttp(message, timeout);
|
|
198
272
|
}
|
|
273
|
+
return this._sendAbly(message, timeout);
|
|
199
274
|
}
|
|
200
275
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
* @param {number|null} [params.keepAlive] - Keep-alive TTL in ms
|
|
212
|
-
*/
|
|
213
|
-
setConnectionParams(params) {
|
|
214
|
-
this._lastConnectParams = params ? { ...params } : null;
|
|
215
|
-
}
|
|
276
|
+
async _sendHttp(message, timeout) {
|
|
277
|
+
var sessionId = this.sessionInstance
|
|
278
|
+
? this.sessionInstance.get()
|
|
279
|
+
: null;
|
|
280
|
+
var body = {
|
|
281
|
+
apiKey: this.apiKey,
|
|
282
|
+
version: version,
|
|
283
|
+
os: message.os || this.os,
|
|
284
|
+
session: sessionId,
|
|
285
|
+
};
|
|
216
286
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
287
|
+
if (message.type === "create") {
|
|
288
|
+
body.os = message.os || this.os || "linux";
|
|
289
|
+
body.resolution = message.resolution;
|
|
290
|
+
body.ci = message.ci;
|
|
291
|
+
if (message.ami) body.ami = message.ami;
|
|
292
|
+
if (message.instanceType) body.instanceType = message.instanceType;
|
|
293
|
+
if (message.keepAlive !== undefined) body.keepAlive = message.keepAlive;
|
|
294
|
+
}
|
|
224
295
|
|
|
225
|
-
if (
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
emitter.emit(events.sandbox.connected);
|
|
231
|
-
// Return the full reply (includes url and sandbox)
|
|
232
|
-
return reply;
|
|
233
|
-
} else {
|
|
234
|
-
// Clear any previous connection params on failure
|
|
235
|
-
this.setConnectionParams(null);
|
|
236
|
-
// Throw error to trigger fallback to creating new sandbox
|
|
237
|
-
throw new Error(reply.errorMessage || "Failed to connect to sandbox");
|
|
296
|
+
if (message.type === "direct") {
|
|
297
|
+
body.ip = message.ip;
|
|
298
|
+
body.resolution = message.resolution;
|
|
299
|
+
body.ci = message.ci;
|
|
300
|
+
if (message.instanceId) body.instanceId = message.instanceId;
|
|
238
301
|
}
|
|
239
|
-
}
|
|
240
302
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
let reply = await this.send({
|
|
248
|
-
type: "direct",
|
|
249
|
-
ip,
|
|
250
|
-
});
|
|
303
|
+
var reply = await httpPost(
|
|
304
|
+
this.apiRoot,
|
|
305
|
+
"/api/v7/sandbox/authenticate",
|
|
306
|
+
body,
|
|
307
|
+
timeout,
|
|
308
|
+
);
|
|
251
309
|
|
|
252
|
-
if (reply.success) {
|
|
310
|
+
if (!reply.success) {
|
|
311
|
+
var err = new Error(
|
|
312
|
+
reply.errorMessage || "Failed to allocate sandbox",
|
|
313
|
+
);
|
|
314
|
+
err.responseData = reply;
|
|
315
|
+
throw err;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
this._sandboxId = reply.sandboxId;
|
|
319
|
+
this._teamId = reply.teamId;
|
|
320
|
+
|
|
321
|
+
if (reply.ably && reply.ably.token) {
|
|
322
|
+
await this._initAbly(reply.ably.token, reply.ably.channels);
|
|
253
323
|
this.instanceSocketConnected = true;
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
324
|
+
|
|
325
|
+
// Tell the runner to enable debug log forwarding if debug mode is on
|
|
326
|
+
var debugMode =
|
|
327
|
+
process.env.VERBOSE || process.env.DEBUG || process.env.TD_DEBUG;
|
|
328
|
+
if (debugMode && this._ctrlChannel) {
|
|
329
|
+
this._ctrlChannel.publish("control", {
|
|
330
|
+
type: "debug",
|
|
331
|
+
enabled: true,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
258
334
|
}
|
|
259
|
-
}
|
|
260
335
|
|
|
261
|
-
|
|
262
|
-
|
|
336
|
+
if (message.type === "create") {
|
|
337
|
+
// E2B (Linux) sandboxes: the API proxies commands and returns a url directly.
|
|
338
|
+
// No runner agent involved — skip runner.ready wait.
|
|
339
|
+
if (reply.url) {
|
|
340
|
+
logger.log(`E2B sandbox ready — url=${reply.url}`);
|
|
341
|
+
return {
|
|
342
|
+
success: true,
|
|
343
|
+
sandbox: {
|
|
344
|
+
sandboxId: reply.sandboxId,
|
|
345
|
+
instanceId: reply.sandbox?.sandboxId || reply.sandboxId,
|
|
346
|
+
os: body.os || 'linux',
|
|
347
|
+
url: reply.url,
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
}
|
|
263
351
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
352
|
+
const runnerIp = reply.runner && reply.runner.ip;
|
|
353
|
+
const noVncPort = reply.runner && reply.runner.noVncPort;
|
|
354
|
+
const runnerVncUrl = reply.runner && reply.runner.vncUrl;
|
|
355
|
+
|
|
356
|
+
logger.log(`Runner claimed — ip=${runnerIp || 'none'}, os=${reply.runner?.os || 'unknown'}, noVncPort=${noVncPort || 'not reported'}, vncUrl=${runnerVncUrl || 'not reported'}`);
|
|
357
|
+
|
|
358
|
+
// For cloud Windows sandboxes (no runner in reply), wait for the
|
|
359
|
+
// agent to signal readiness before sending commands. Without this
|
|
360
|
+
// gate, commands published before the agent subscribes are lost.
|
|
361
|
+
var self = this;
|
|
362
|
+
if (!reply.runner && this._ctrlChannel) {
|
|
363
|
+
logger.log('Waiting for runner agent to signal readiness...');
|
|
364
|
+
var readyTimeout = 120000; // 120s — allows for EC2 boot + agent startup
|
|
365
|
+
await new Promise(function (resolve, reject) {
|
|
366
|
+
var resolved = false;
|
|
367
|
+
function finish(data) {
|
|
368
|
+
if (resolved) return;
|
|
369
|
+
resolved = true;
|
|
370
|
+
clearTimeout(timer);
|
|
371
|
+
self._ctrlChannel.unsubscribe('control', onCtrl);
|
|
372
|
+
// Update runner info if provided
|
|
373
|
+
if (data && data.os) reply.runner = reply.runner || {};
|
|
374
|
+
if (data && data.os && reply.runner) reply.runner.os = data.os;
|
|
375
|
+
if (data && data.ip && reply.runner) reply.runner.ip = data.ip;
|
|
376
|
+
logger.log('Runner agent ready (os=' + ((data && data.os) || 'unknown') + ')');
|
|
377
|
+
resolve();
|
|
378
|
+
}
|
|
267
379
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
380
|
+
var timer = setTimeout(function () {
|
|
381
|
+
if (!resolved) {
|
|
382
|
+
resolved = true;
|
|
383
|
+
self._ctrlChannel.unsubscribe('control', onCtrl);
|
|
384
|
+
reject(new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms'));
|
|
385
|
+
}
|
|
386
|
+
}, readyTimeout);
|
|
387
|
+
if (timer.unref) timer.unref();
|
|
388
|
+
|
|
389
|
+
// Listen for live runner.ready messages
|
|
390
|
+
var onCtrl;
|
|
391
|
+
onCtrl = function (msg) {
|
|
392
|
+
var data = msg.data;
|
|
393
|
+
if (data && data.type === 'runner.ready') {
|
|
394
|
+
finish(data);
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
self._ctrlChannel.subscribe('control', onCtrl);
|
|
398
|
+
|
|
399
|
+
// Also check channel history in case runner.ready was published
|
|
400
|
+
// before we subscribed (race condition on fast-booting agents).
|
|
401
|
+
try {
|
|
402
|
+
self._ctrlChannel.history({ limit: 50 }, function (err, page) {
|
|
403
|
+
if (err) {
|
|
404
|
+
logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (page && page.items) {
|
|
408
|
+
for (var i = 0; i < page.items.length; i++) {
|
|
409
|
+
var item = page.items[i];
|
|
410
|
+
if (item.name === 'control' && item.data && item.data.type === 'runner.ready') {
|
|
411
|
+
logger.log('Found runner.ready in channel history');
|
|
412
|
+
finish(item.data);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
} catch (histErr) {
|
|
419
|
+
logger.warn('History call threw (non-fatal): ' + (histErr.message || histErr));
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
// Prefer the full vncUrl reported by the runner (infrastructure-agnostic).
|
|
424
|
+
// Fall back to constructing from ip + noVncPort for older runners.
|
|
425
|
+
let url;
|
|
426
|
+
if (runnerVncUrl) {
|
|
427
|
+
url = runnerVncUrl;
|
|
428
|
+
logger.log(`Using runner-provided vncUrl: ${url}`);
|
|
429
|
+
} else if (runnerIp && noVncPort) {
|
|
430
|
+
url = `http://${runnerIp}:${noVncPort}/vnc_lite.html`;
|
|
431
|
+
logger.log(`noVNC URL constructed from runner ip+port: ${url}`);
|
|
432
|
+
} else if (runnerIp) {
|
|
433
|
+
url = "http://" + runnerIp;
|
|
434
|
+
logger.warn(`Runner did not report noVNC port — using bare IP: ${url}`);
|
|
435
|
+
} else {
|
|
436
|
+
logger.warn('Runner has no IP — preview will not be available');
|
|
275
437
|
}
|
|
438
|
+
return {
|
|
439
|
+
success: true,
|
|
440
|
+
sandbox: {
|
|
441
|
+
sandboxId: reply.sandboxId,
|
|
442
|
+
instanceId: reply.sandboxId,
|
|
443
|
+
os: reply.runner?.os || body.os,
|
|
444
|
+
ip: runnerIp,
|
|
445
|
+
url: url,
|
|
446
|
+
vncPort: noVncPort || undefined,
|
|
447
|
+
runner: reply.runner,
|
|
448
|
+
},
|
|
449
|
+
};
|
|
276
450
|
}
|
|
277
451
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
452
|
+
if (message.type === "direct") {
|
|
453
|
+
// If the API provisioned Ably credentials to the instance (reply.agent present),
|
|
454
|
+
// wait for the runner agent to signal readiness before sending commands.
|
|
455
|
+
// Without this gate, commands published before the agent subscribes are lost.
|
|
456
|
+
var self = this;
|
|
457
|
+
if (reply.agent && this._ctrlChannel) {
|
|
458
|
+
logger.log('Waiting for runner agent to signal readiness (direct connection)...');
|
|
459
|
+
var readyTimeout = 120000; // 120s — allows for SSM provisioning + agent startup
|
|
460
|
+
await new Promise(function (resolve, reject) {
|
|
461
|
+
var resolved = false;
|
|
462
|
+
function finish(data) {
|
|
463
|
+
if (resolved) return;
|
|
464
|
+
resolved = true;
|
|
465
|
+
clearTimeout(timer);
|
|
466
|
+
self._ctrlChannel.unsubscribe('control', onCtrl);
|
|
467
|
+
logger.log('Runner agent ready (direct, os=' + ((data && data.os) || 'unknown') + ')');
|
|
468
|
+
resolve();
|
|
291
469
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
470
|
+
|
|
471
|
+
var timer = setTimeout(function () {
|
|
472
|
+
if (!resolved) {
|
|
473
|
+
resolved = true;
|
|
474
|
+
self._ctrlChannel.unsubscribe('control', onCtrl);
|
|
475
|
+
reject(new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms (direct connection)'));
|
|
476
|
+
}
|
|
477
|
+
}, readyTimeout);
|
|
478
|
+
if (timer.unref) timer.unref();
|
|
479
|
+
|
|
480
|
+
// Listen for live runner.ready messages
|
|
481
|
+
var onCtrl;
|
|
482
|
+
onCtrl = function (msg) {
|
|
483
|
+
var data = msg.data;
|
|
484
|
+
if (data && data.type === 'runner.ready') {
|
|
485
|
+
finish(data);
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
self._ctrlChannel.subscribe('control', onCtrl);
|
|
489
|
+
|
|
490
|
+
// Also check channel history in case runner.ready was published
|
|
491
|
+
// before we subscribed (race condition on fast-booting agents).
|
|
492
|
+
try {
|
|
493
|
+
self._ctrlChannel.history({ limit: 50 }, function (err, page) {
|
|
494
|
+
if (err) {
|
|
495
|
+
logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (page && page.items) {
|
|
499
|
+
for (var i = 0; i < page.items.length; i++) {
|
|
500
|
+
var item = page.items[i];
|
|
501
|
+
if (item.name === 'control' && item.data && item.data.type === 'runner.ready') {
|
|
502
|
+
logger.log('Found runner.ready in channel history (direct)');
|
|
503
|
+
finish(item.data);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
} catch (histErr) {
|
|
510
|
+
logger.warn('History call threw (non-fatal): ' + (histErr.message || histErr));
|
|
511
|
+
}
|
|
512
|
+
});
|
|
299
513
|
}
|
|
300
|
-
|
|
514
|
+
|
|
515
|
+
// Construct VNC URL — use port 8080 (nginx noVNC proxy) for Windows instances
|
|
516
|
+
var directUrl = message.ip ? "http://" + message.ip + ":8080/vnc_lite.html" : undefined;
|
|
517
|
+
|
|
518
|
+
return {
|
|
519
|
+
success: true,
|
|
520
|
+
instance: {
|
|
521
|
+
instanceId: reply.sandboxId,
|
|
522
|
+
sandboxId: reply.sandboxId,
|
|
523
|
+
ip: message.ip,
|
|
524
|
+
url: directUrl || "http://" + message.ip,
|
|
525
|
+
},
|
|
526
|
+
};
|
|
301
527
|
}
|
|
302
528
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
529
|
+
return reply;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
_sendAbly(message, timeout) {
|
|
533
|
+
if (timeout === undefined) timeout = 300000;
|
|
534
|
+
|
|
535
|
+
if (!this._cmdChannel || !this._ably) {
|
|
536
|
+
return Promise.reject(
|
|
537
|
+
new Error("Sandbox not connected (no Ably client)"),
|
|
538
|
+
);
|
|
307
539
|
}
|
|
308
540
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
541
|
+
// If temporarily disconnected, wait up to 30s for reconnection
|
|
542
|
+
// instead of failing immediately (dashcam uploads can cause brief blips)
|
|
543
|
+
var self = this;
|
|
544
|
+
var connState = this._ably.connection.state;
|
|
545
|
+
if (connState !== "connected") {
|
|
546
|
+
if (connState === "disconnected" || connState === "connecting" || connState === "suspended") {
|
|
547
|
+
logger.log("Ably is " + connState + ", waiting for reconnect before sending...");
|
|
548
|
+
var waitForConnect = new Promise(function (resolve, reject) {
|
|
549
|
+
var timer = setTimeout(function () {
|
|
550
|
+
self._ably.connection.off("connected", onConnected);
|
|
551
|
+
self._ably.connection.off("failed", onFailed);
|
|
552
|
+
reject(new Error("Sandbox not connected after waiting 30s (state: " + self._ably.connection.state + ")"));
|
|
553
|
+
}, 30000);
|
|
554
|
+
if (timer.unref) timer.unref();
|
|
555
|
+
function onConnected() {
|
|
556
|
+
clearTimeout(timer);
|
|
557
|
+
self._ably.connection.off("failed", onFailed);
|
|
558
|
+
resolve();
|
|
559
|
+
}
|
|
560
|
+
function onFailed() {
|
|
561
|
+
clearTimeout(timer);
|
|
562
|
+
self._ably.connection.off("connected", onConnected);
|
|
563
|
+
reject(new Error("Ably connection failed while waiting to send"));
|
|
564
|
+
}
|
|
565
|
+
self._ably.connection.once("connected", onConnected);
|
|
566
|
+
self._ably.connection.once("failed", onFailed);
|
|
567
|
+
});
|
|
568
|
+
return waitForConnect.then(function () {
|
|
569
|
+
return self._sendAbly(message, timeout);
|
|
570
|
+
});
|
|
322
571
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
572
|
+
return Promise.reject(
|
|
573
|
+
new Error("Sandbox not connected (state: " + connState + ")"),
|
|
574
|
+
);
|
|
326
575
|
}
|
|
327
576
|
|
|
328
|
-
this.
|
|
329
|
-
|
|
577
|
+
this.messageId++;
|
|
578
|
+
message.requestId = this.uniqueId + "-" + this.messageId;
|
|
330
579
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
);
|
|
580
|
+
if (message.os) this.os = message.os;
|
|
581
|
+
if (this.os && !message.os) message.os = this.os;
|
|
334
582
|
|
|
335
|
-
this.
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
const { ip, persist, keepAlive } = this._lastConnectParams;
|
|
349
|
-
console.log(`[Sandbox] Re-establishing direct connection (${ip})...`);
|
|
350
|
-
await this.reconnectDirect(ip);
|
|
351
|
-
} else {
|
|
352
|
-
const { sandboxId, persist, keepAlive } = this._lastConnectParams;
|
|
353
|
-
console.log(`[Sandbox] Re-establishing sandbox connection (${sandboxId})...`);
|
|
354
|
-
await this.connect(sandboxId, persist, keepAlive);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
console.log("[Sandbox] Reconnected successfully.");
|
|
358
|
-
|
|
359
|
-
// Retry queued requests
|
|
360
|
-
await this._retryQueuedRequests();
|
|
361
|
-
} catch (e) {
|
|
362
|
-
// boot's close handler will trigger handleConnectionLoss again
|
|
363
|
-
} finally {
|
|
364
|
-
this.reconnecting = false;
|
|
583
|
+
if (this.sessionInstance && !message.session) {
|
|
584
|
+
var sessionId = this.sessionInstance.get();
|
|
585
|
+
if (sessionId) message.session = sessionId;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (
|
|
589
|
+
this._lastConnectParams &&
|
|
590
|
+
this._lastConnectParams.sandboxId &&
|
|
591
|
+
!message.sandboxId
|
|
592
|
+
) {
|
|
593
|
+
var id = this._lastConnectParams.sandboxId;
|
|
594
|
+
if (id && !/^\d+\.\d+\.\d+\.\d+$/.test(id)) {
|
|
595
|
+
message.sandboxId = id;
|
|
365
596
|
}
|
|
366
|
-
}, delay);
|
|
367
|
-
// Don't let reconnect timer prevent Node process from exiting
|
|
368
|
-
if (this.reconnectTimer.unref) {
|
|
369
|
-
this.reconnectTimer.unref();
|
|
370
597
|
}
|
|
371
|
-
}
|
|
372
598
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
// Take all queued requests and clear the queue
|
|
383
|
-
const toRetry = this.pendingRetryQueue.splice(0);
|
|
384
|
-
|
|
385
|
-
for (const queued of toRetry) {
|
|
386
|
-
try {
|
|
387
|
-
// Re-send the message and resolve/reject the original promise
|
|
388
|
-
const result = await this.send(queued.message);
|
|
389
|
-
queued.resolve(result);
|
|
390
|
-
} catch (err) {
|
|
391
|
-
queued.reject(err);
|
|
599
|
+
// Attach Sentry distributed trace headers for runner-side tracing
|
|
600
|
+
var traceSessionId = this.sessionInstance
|
|
601
|
+
? this.sessionInstance.get()
|
|
602
|
+
: message.session;
|
|
603
|
+
if (traceSessionId) {
|
|
604
|
+
var traceHeaders = getSentryTraceHeaders(traceSessionId);
|
|
605
|
+
if (traceHeaders["sentry-trace"]) {
|
|
606
|
+
message.sentryTrace = traceHeaders["sentry-trace"];
|
|
607
|
+
message.baggage = traceHeaders.baggage;
|
|
392
608
|
}
|
|
393
609
|
}
|
|
394
|
-
|
|
395
|
-
console.log(`[Sandbox] Finished retrying queued requests.`);
|
|
396
|
-
}
|
|
397
610
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
611
|
+
var resolvePromise, rejectPromise;
|
|
612
|
+
var self = this;
|
|
613
|
+
|
|
614
|
+
var p = new Promise(function (resolve, reject) {
|
|
615
|
+
resolvePromise = resolve;
|
|
616
|
+
rejectPromise = reject;
|
|
617
|
+
});
|
|
403
618
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
619
|
+
var requestId = message.requestId;
|
|
620
|
+
|
|
621
|
+
var timeoutId = setTimeout(function () {
|
|
622
|
+
if (self.ps[requestId]) {
|
|
623
|
+
delete self.ps[requestId];
|
|
624
|
+
rejectPromise(
|
|
625
|
+
new Error(
|
|
626
|
+
"Sandbox message '" +
|
|
627
|
+
message.type +
|
|
628
|
+
"' timed out after " +
|
|
629
|
+
timeout +
|
|
630
|
+
"ms",
|
|
631
|
+
),
|
|
407
632
|
);
|
|
408
633
|
}
|
|
634
|
+
}, timeout);
|
|
635
|
+
if (timeoutId.unref) timeoutId.unref();
|
|
636
|
+
|
|
637
|
+
this.ps[requestId] = {
|
|
638
|
+
promise: p,
|
|
639
|
+
resolve: function (result) {
|
|
640
|
+
clearTimeout(timeoutId);
|
|
641
|
+
resolvePromise(result);
|
|
642
|
+
},
|
|
643
|
+
reject: function (error) {
|
|
644
|
+
clearTimeout(timeoutId);
|
|
645
|
+
rejectPromise(error);
|
|
646
|
+
},
|
|
647
|
+
message: message,
|
|
648
|
+
startTime: Date.now(),
|
|
649
|
+
};
|
|
409
650
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const wsUrl = new URL(apiRoot.replace("https://", "wss://"));
|
|
414
|
-
if (sentryHeaders["sentry-trace"]) {
|
|
415
|
-
wsUrl.searchParams.set("sentry-trace", sentryHeaders["sentry-trace"]);
|
|
416
|
-
}
|
|
417
|
-
if (sentryHeaders["baggage"]) {
|
|
418
|
-
wsUrl.searchParams.set("baggage", sentryHeaders["baggage"]);
|
|
419
|
-
}
|
|
651
|
+
if (message.type === "output") {
|
|
652
|
+
p.catch(function () {});
|
|
653
|
+
}
|
|
420
654
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
655
|
+
this._cmdChannel
|
|
656
|
+
.publish("command", message)
|
|
657
|
+
.then(function () {
|
|
658
|
+
emitter.emit(events.sandbox.sent, message);
|
|
659
|
+
})
|
|
660
|
+
.catch(function (err) {
|
|
661
|
+
if (self.ps[requestId]) {
|
|
662
|
+
clearTimeout(timeoutId);
|
|
663
|
+
delete self.ps[requestId];
|
|
664
|
+
rejectPromise(
|
|
665
|
+
new Error("Failed to send message: " + err.message),
|
|
666
|
+
);
|
|
667
|
+
}
|
|
434
668
|
});
|
|
435
669
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
err && logger.log(err);
|
|
439
|
-
clearInterval(this.heartbeat);
|
|
440
|
-
emitter.emit(events.error.sandbox, err);
|
|
441
|
-
this.apiSocketConnected = false;
|
|
442
|
-
// Don't call handleConnectionLoss here - the 'close' event always fires
|
|
443
|
-
// after 'error', so let 'close' handle reconnection to avoid duplicate attempts
|
|
444
|
-
reject(err);
|
|
445
|
-
});
|
|
670
|
+
return p;
|
|
671
|
+
}
|
|
446
672
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
673
|
+
async auth(apiKey) {
|
|
674
|
+
this.apiKey = apiKey;
|
|
675
|
+
var sessionId = this.sessionInstance
|
|
676
|
+
? this.sessionInstance.get()
|
|
677
|
+
: null;
|
|
678
|
+
|
|
679
|
+
var reply = await httpPost(
|
|
680
|
+
this.apiRoot,
|
|
681
|
+
"/api/v7/sandbox/authenticate",
|
|
682
|
+
{
|
|
683
|
+
apiKey: apiKey,
|
|
684
|
+
version: version,
|
|
685
|
+
session: sessionId,
|
|
686
|
+
},
|
|
687
|
+
);
|
|
451
688
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
}, 5000);
|
|
457
|
-
// Don't let the heartbeat interval prevent Node process from exiting
|
|
458
|
-
if (this.heartbeat.unref) {
|
|
459
|
-
this.heartbeat.unref();
|
|
460
|
-
}
|
|
689
|
+
if (reply.success) {
|
|
690
|
+
this.authenticated = true;
|
|
691
|
+
this.apiSocketConnected = true;
|
|
692
|
+
this._teamId = reply.teamId;
|
|
461
693
|
|
|
462
|
-
|
|
694
|
+
if (reply.traceId) {
|
|
695
|
+
this.traceId = reply.traceId;
|
|
696
|
+
logger.log("");
|
|
697
|
+
logger.log("Trace Report (Share When Reporting Bugs):");
|
|
698
|
+
logger.log(
|
|
699
|
+
"https://testdriver.sentry.io/explore/traces/trace/" +
|
|
700
|
+
reply.traceId,
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
emitter.emit(events.sandbox.authenticated, {
|
|
705
|
+
traceId: reply.traceId,
|
|
463
706
|
});
|
|
707
|
+
return true;
|
|
708
|
+
}
|
|
464
709
|
|
|
465
|
-
|
|
466
|
-
|
|
710
|
+
return false;
|
|
711
|
+
}
|
|
467
712
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
step: message.step,
|
|
472
|
-
message: message.message,
|
|
473
|
-
});
|
|
474
|
-
return;
|
|
475
|
-
}
|
|
713
|
+
setConnectionParams(params) {
|
|
714
|
+
this._lastConnectParams = params ? Object.assign({}, params) : null;
|
|
715
|
+
}
|
|
476
716
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
717
|
+
async connect(sandboxId, persist, keepAlive) {
|
|
718
|
+
if (persist === undefined) persist = false;
|
|
719
|
+
if (keepAlive === undefined) keepAlive = null;
|
|
720
|
+
var sessionId = this.sessionInstance
|
|
721
|
+
? this.sessionInstance.get()
|
|
722
|
+
: null;
|
|
723
|
+
|
|
724
|
+
var reply = await httpPost(
|
|
725
|
+
this.apiRoot,
|
|
726
|
+
"/api/v7/sandbox/authenticate",
|
|
727
|
+
{
|
|
728
|
+
apiKey: this.apiKey,
|
|
729
|
+
version: version,
|
|
730
|
+
sandboxId: sandboxId,
|
|
731
|
+
session: sessionId,
|
|
732
|
+
keepAlive: keepAlive || undefined,
|
|
733
|
+
},
|
|
734
|
+
);
|
|
493
735
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
});
|
|
736
|
+
if (!reply.success) {
|
|
737
|
+
this.setConnectionParams(null);
|
|
738
|
+
throw new Error(reply.errorMessage || "Failed to connect to sandbox");
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
this._sandboxId = reply.sandboxId;
|
|
742
|
+
|
|
743
|
+
if (reply.ably && reply.ably.token) {
|
|
744
|
+
await this._initAbly(reply.ably.token, reply.ably.channels);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
this.setConnectionParams({
|
|
748
|
+
sandboxId: sandboxId,
|
|
749
|
+
persist: persist,
|
|
750
|
+
keepAlive: keepAlive,
|
|
510
751
|
});
|
|
752
|
+
this.instanceSocketConnected = true;
|
|
753
|
+
emitter.emit(events.sandbox.connected);
|
|
754
|
+
|
|
755
|
+
// Prefer runner-provided vncUrl, fall back to ip+port, then bare IP
|
|
756
|
+
const reconnectRunner = reply.runner || {};
|
|
757
|
+
const reconnectVncUrl = reconnectRunner.vncUrl;
|
|
758
|
+
const reconnectNoVncPort = reconnectRunner.noVncPort;
|
|
759
|
+
const reconnectIp = reconnectRunner.ip;
|
|
760
|
+
let reconnectUrl;
|
|
761
|
+
if (reconnectVncUrl) {
|
|
762
|
+
reconnectUrl = reconnectVncUrl;
|
|
763
|
+
} else if (reconnectIp && reconnectNoVncPort) {
|
|
764
|
+
reconnectUrl = `http://${reconnectIp}:${reconnectNoVncPort}/vnc_lite.html`;
|
|
765
|
+
} else if (reconnectIp) {
|
|
766
|
+
reconnectUrl = "http://" + reconnectIp;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
return {
|
|
770
|
+
success: true,
|
|
771
|
+
url: reconnectUrl,
|
|
772
|
+
sandbox: {
|
|
773
|
+
sandboxId: reply.sandboxId,
|
|
774
|
+
instanceId: reply.sandboxId,
|
|
775
|
+
os: reconnectRunner.os || undefined,
|
|
776
|
+
ip: reconnectIp || undefined,
|
|
777
|
+
url: reconnectUrl,
|
|
778
|
+
vncPort: reconnectNoVncPort || undefined,
|
|
779
|
+
},
|
|
780
|
+
};
|
|
511
781
|
}
|
|
512
782
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
this.intentionalDisconnect = true;
|
|
518
|
-
this.reconnecting = false;
|
|
519
|
-
// Cancel any pending reconnect timer
|
|
520
|
-
if (this.reconnectTimer) {
|
|
521
|
-
clearTimeout(this.reconnectTimer);
|
|
522
|
-
this.reconnectTimer = null;
|
|
523
|
-
}
|
|
783
|
+
async boot(apiRoot) {
|
|
784
|
+
if (apiRoot) this.apiRoot = apiRoot;
|
|
785
|
+
return this;
|
|
786
|
+
}
|
|
524
787
|
|
|
788
|
+
async close() {
|
|
525
789
|
if (this.heartbeat) {
|
|
526
790
|
clearInterval(this.heartbeat);
|
|
527
791
|
this.heartbeat = null;
|
|
528
792
|
}
|
|
529
793
|
|
|
530
|
-
//
|
|
531
|
-
|
|
532
|
-
|
|
794
|
+
// Send end-session control message to runner before disconnecting
|
|
795
|
+
if (this._ctrlChannel && this._ably?.connection?.state === 'connected') {
|
|
796
|
+
try {
|
|
797
|
+
await this._ctrlChannel.publish('control', { type: 'end-session' });
|
|
798
|
+
} catch (e) {
|
|
799
|
+
// Ignore - best effort
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
try {
|
|
804
|
+
if (this._cmdChannel) this._cmdChannel.detach().catch(() => {});
|
|
805
|
+
if (this._respChannel) this._respChannel.detach().catch(() => {});
|
|
806
|
+
if (this._ctrlChannel) this._ctrlChannel.detach().catch(() => {});
|
|
807
|
+
if (this._filesChannel) this._filesChannel.detach().catch(() => {});
|
|
808
|
+
} catch (e) {
|
|
809
|
+
/* ignore */
|
|
533
810
|
}
|
|
534
|
-
this.pendingTimeouts.clear();
|
|
535
811
|
|
|
536
|
-
if (this.
|
|
537
|
-
// Remove all listeners before closing to prevent reconnect attempts
|
|
538
|
-
this.socket.removeAllListeners();
|
|
812
|
+
if (this._ably) {
|
|
539
813
|
try {
|
|
540
|
-
this.
|
|
541
|
-
} catch (
|
|
542
|
-
|
|
814
|
+
this._ably.close();
|
|
815
|
+
} catch (e) {
|
|
816
|
+
/* ignore */
|
|
543
817
|
}
|
|
544
|
-
this.
|
|
818
|
+
this._ably = null;
|
|
545
819
|
}
|
|
546
820
|
|
|
821
|
+
this._cmdChannel = null;
|
|
822
|
+
this._respChannel = null;
|
|
823
|
+
this._ctrlChannel = null;
|
|
824
|
+
this._filesChannel = null;
|
|
825
|
+
this._channelNames = null;
|
|
547
826
|
this.apiSocketConnected = false;
|
|
548
827
|
this.instanceSocketConnected = false;
|
|
549
828
|
this.authenticated = false;
|
|
550
829
|
this.instance = null;
|
|
551
830
|
this._lastConnectParams = null;
|
|
552
|
-
|
|
553
|
-
// Silently clear pending promises and retry queue without rejecting
|
|
554
|
-
// (rejecting causes unhandled promise rejections during cleanup)
|
|
555
831
|
this.ps = {};
|
|
556
|
-
this.pendingRetryQueue = [];
|
|
557
832
|
}
|
|
558
833
|
}
|
|
559
834
|
|