@sandboxrouter/sdk 0.2.0 → 0.3.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/README.md +224 -48
- package/dist/bench.js +4 -4
- package/dist/catalog.d.ts +159 -0
- package/dist/catalog.js +448 -15
- package/dist/check.d.ts +28 -0
- package/dist/check.js +185 -0
- package/dist/cli.js +148 -11
- package/dist/computers.d.ts +55 -13
- package/dist/computers.js +132 -6
- package/dist/conformance.d.ts +46 -0
- package/dist/conformance.js +65 -0
- package/dist/cost.d.ts +67 -0
- package/dist/cost.js +153 -0
- package/dist/gc.d.ts +29 -0
- package/dist/gc.js +54 -0
- package/dist/index.d.ts +28 -2
- package/dist/index.js +28 -2
- package/dist/leaderboard.d.ts +6 -0
- package/dist/leaderboard.js +5 -4
- package/dist/ledger.d.ts +38 -0
- package/dist/ledger.js +89 -0
- package/dist/providers/agent37.d.ts +40 -0
- package/dist/providers/agent37.js +224 -0
- package/dist/providers/agentcore.d.ts +71 -0
- package/dist/providers/agentcore.js +446 -0
- package/dist/providers/alibaba.d.ts +18 -0
- package/dist/providers/alibaba.js +106 -0
- package/dist/providers/azure.d.ts +43 -0
- package/dist/providers/azure.js +330 -0
- package/dist/providers/beam.d.ts +11 -0
- package/dist/providers/beam.js +179 -0
- package/dist/providers/blaxel.d.ts +3 -1
- package/dist/providers/blaxel.js +32 -22
- package/dist/providers/boat.js +8 -3
- package/dist/providers/cloudflare.js +8 -3
- package/dist/providers/codesandbox.d.ts +59 -0
- package/dist/providers/codesandbox.js +287 -0
- package/dist/providers/daytona.d.ts +25 -4
- package/dist/providers/daytona.js +449 -28
- package/dist/providers/dedalus.d.ts +12 -0
- package/dist/providers/dedalus.js +244 -0
- package/dist/providers/deno.d.ts +50 -0
- package/dist/providers/deno.js +170 -0
- package/dist/providers/docker.js +11 -5
- package/dist/providers/e2b.d.ts +70 -3
- package/dist/providers/e2b.js +376 -8
- package/dist/providers/exedev.js +17 -5
- package/dist/providers/freestyle.d.ts +49 -0
- package/dist/providers/freestyle.js +261 -0
- package/dist/providers/hopx.js +27 -18
- package/dist/providers/instabox.d.ts +76 -0
- package/dist/providers/instabox.js +360 -0
- package/dist/providers/koyeb.d.ts +71 -0
- package/dist/providers/koyeb.js +379 -0
- package/dist/providers/lambda.d.ts +51 -0
- package/dist/providers/lambda.js +467 -0
- package/dist/providers/langsmith.d.ts +16 -0
- package/dist/providers/langsmith.js +149 -0
- package/dist/providers/limrun.d.ts +77 -0
- package/dist/providers/limrun.js +626 -0
- package/dist/providers/maritime.d.ts +5 -1
- package/dist/providers/maritime.js +39 -8
- package/dist/providers/mobilerun.d.ts +44 -0
- package/dist/providers/mobilerun.js +417 -0
- package/dist/providers/modal.js +12 -4
- package/dist/providers/morph.js +12 -4
- package/dist/providers/northflank.js +11 -13
- package/dist/providers/novita.d.ts +12 -0
- package/dist/providers/novita.js +81 -0
- package/dist/providers/openscale.d.ts +17 -0
- package/dist/providers/openscale.js +87 -0
- package/dist/providers/orgo.d.ts +45 -0
- package/dist/providers/orgo.js +414 -0
- package/dist/providers/sail.d.ts +32 -0
- package/dist/providers/sail.js +372 -0
- package/dist/providers/superserve.d.ts +25 -0
- package/dist/providers/superserve.js +337 -0
- package/dist/providers/tensorlake.js +17 -14
- package/dist/providers/upstash.d.ts +30 -0
- package/dist/providers/upstash.js +258 -0
- package/dist/providers/vercel.js +14 -3
- package/dist/providers/workspaces.d.ts +60 -0
- package/dist/providers/workspaces.js +662 -0
- package/dist/router.d.ts +36 -5
- package/dist/router.js +149 -19
- package/dist/server.d.ts +1 -1
- package/dist/server.js +22 -3
- package/dist/types.d.ts +13 -5
- package/dist/util.d.ts +28 -1
- package/dist/util.js +56 -7
- package/package.json +80 -5
package/dist/providers/e2b.js
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { ProviderError } from "../types.js";
|
|
2
|
-
import { optionalImport, toBytes } from "../util.js";
|
|
3
|
-
/**
|
|
2
|
+
import { DEFAULT_EXEC_TIMEOUT_S, optionalImport, timeoutError, toBytes } from "../util.js";
|
|
3
|
+
/** How long a desktop lives without activity. 1 hour is the Hobby tier's cap (Pro allows 24 h). */
|
|
4
|
+
const DESKTOP_TIMEOUT_MS = 60 * 60_000;
|
|
5
|
+
/**
|
|
6
|
+
* E2B via the official `e2b` SDK. vCPU and memory come from the template, not the create call.
|
|
7
|
+
* Computers (Linux desktops) via `@e2b/desktop`: the public `desktop` template (Xvfb + XFCE),
|
|
8
|
+
* driven with xdotool and scrot inside the sandbox.
|
|
9
|
+
*/
|
|
4
10
|
export class E2BProvider {
|
|
5
11
|
apiKey;
|
|
6
12
|
id = "e2b";
|
|
13
|
+
computerOs = ["linux"];
|
|
7
14
|
constructor(apiKey = process.env.E2B_API_KEY) {
|
|
8
15
|
this.apiKey = apiKey;
|
|
9
16
|
}
|
|
@@ -25,32 +32,96 @@ export class E2BProvider {
|
|
|
25
32
|
const { Sandbox } = await optionalImport("e2b", "e2b");
|
|
26
33
|
return new E2BBox(await Sandbox.connect(nativeId, { apiKey: this.apiKey }));
|
|
27
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* A fresh desktop every call. E2B has no get-or-create by user, so `externalUserId` (and `name`)
|
|
37
|
+
* are only stored as sandbox metadata; keep the returned id to come back to the same desktop.
|
|
38
|
+
*/
|
|
39
|
+
async createComputer(spec) {
|
|
40
|
+
const os = spec.os ?? "linux";
|
|
41
|
+
if (os !== "linux")
|
|
42
|
+
throw new ProviderError(this.id, `E2B only offers linux desktops, not ${os}`);
|
|
43
|
+
const { Sandbox } = await optionalImport("@e2b/desktop", "e2b computers");
|
|
44
|
+
const metadata = {};
|
|
45
|
+
if (spec.externalUserId !== undefined)
|
|
46
|
+
metadata.externalUserId = spec.externalUserId;
|
|
47
|
+
if (spec.name !== undefined)
|
|
48
|
+
metadata.name = spec.name;
|
|
49
|
+
try {
|
|
50
|
+
const sbx = await Sandbox.create({
|
|
51
|
+
apiKey: this.apiKey,
|
|
52
|
+
timeoutMs: DESKTOP_TIMEOUT_MS,
|
|
53
|
+
metadata,
|
|
54
|
+
...(spec.screen ? { resolution: [Math.round(spec.screen.width), Math.round(spec.screen.height)] } : {}),
|
|
55
|
+
});
|
|
56
|
+
return new E2BComputer(sbx);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
throw new ProviderError(this.id, err?.message ?? String(err));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Reattach by sandbox id. A paused (slept) desktop is resumed. */
|
|
63
|
+
async connectComputer(nativeId) {
|
|
64
|
+
const { Sandbox } = await optionalImport("@e2b/desktop", "e2b computers");
|
|
65
|
+
try {
|
|
66
|
+
return new E2BComputer(await Sandbox.connect(nativeId, { apiKey: this.apiKey, timeoutMs: DESKTOP_TIMEOUT_MS }));
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
throw new ProviderError(this.id, err?.message ?? String(err));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
28
72
|
}
|
|
29
|
-
|
|
73
|
+
/** exec/files/kill over an E2B-style SDK sandbox. `provider` labels errors. No pause. */
|
|
74
|
+
export class E2BCompatBox {
|
|
30
75
|
sbx;
|
|
76
|
+
provider;
|
|
31
77
|
nativeId;
|
|
32
|
-
constructor(sbx) {
|
|
78
|
+
constructor(sbx, provider = "e2b") {
|
|
33
79
|
this.sbx = sbx;
|
|
80
|
+
this.provider = provider;
|
|
34
81
|
this.nativeId = sbx.sandboxId;
|
|
35
82
|
}
|
|
36
83
|
async exec(cmd, opts = {}) {
|
|
37
84
|
const t0 = Date.now();
|
|
85
|
+
const seconds = opts.timeoutSeconds ?? DEFAULT_EXEC_TIMEOUT_S;
|
|
86
|
+
let timer;
|
|
87
|
+
const expired = new Promise((resolve) => {
|
|
88
|
+
timer = setTimeout(() => resolve("timeout"), seconds * 1000);
|
|
89
|
+
timer.unref();
|
|
90
|
+
});
|
|
38
91
|
try {
|
|
39
|
-
|
|
92
|
+
// The command runs in the background so the deadline can kill it: the SDK's own `timeoutMs`
|
|
93
|
+
// only stops listening, and the process would keep running (and billing) in the sandbox.
|
|
94
|
+
// That stream deadline is set past ours so it never ends a command we still want.
|
|
95
|
+
const handle = await this.sbx.commands.run(cmd, {
|
|
40
96
|
cwd: opts.cwd,
|
|
41
97
|
envs: opts.env,
|
|
42
|
-
|
|
98
|
+
background: true,
|
|
99
|
+
timeoutMs: (seconds + 15) * 1000,
|
|
43
100
|
onStdout: opts.onStdout,
|
|
44
101
|
onStderr: opts.onStderr,
|
|
45
102
|
});
|
|
103
|
+
const finished = handle.wait();
|
|
104
|
+
const r = await Promise.race([finished, expired]);
|
|
105
|
+
if (r === "timeout") {
|
|
106
|
+
finished.catch(() => { });
|
|
107
|
+
await handle.kill().catch(() => { });
|
|
108
|
+
throw timeoutError(this.provider, seconds);
|
|
109
|
+
}
|
|
46
110
|
return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr, durationMs: Date.now() - t0 };
|
|
47
111
|
}
|
|
48
112
|
catch (err) {
|
|
113
|
+
if (err instanceof ProviderError)
|
|
114
|
+
throw err;
|
|
49
115
|
// The SDK throws CommandExitError for a nonzero exit; that's a result, not a failure.
|
|
50
116
|
if (err?.name === "CommandExitError" || typeof err?.exitCode === "number") {
|
|
51
117
|
return { exitCode: err.exitCode, stdout: err.stdout ?? "", stderr: err.stderr ?? "", durationMs: Date.now() - t0 };
|
|
52
118
|
}
|
|
53
|
-
|
|
119
|
+
if (err?.name === "TimeoutError")
|
|
120
|
+
throw timeoutError(this.provider, seconds);
|
|
121
|
+
throw new ProviderError(this.provider, err?.message ?? String(err));
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
clearTimeout(timer);
|
|
54
125
|
}
|
|
55
126
|
}
|
|
56
127
|
async writeFile(path, content) {
|
|
@@ -59,11 +130,308 @@ class E2BBox {
|
|
|
59
130
|
async readFile(path) {
|
|
60
131
|
return await this.sbx.files.read(path, { format: "bytes" });
|
|
61
132
|
}
|
|
133
|
+
async kill() {
|
|
134
|
+
await this.sbx.kill();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** E2BCompatBox plus pause, for services where pause/resume is generally available. */
|
|
138
|
+
export class E2BBox extends E2BCompatBox {
|
|
62
139
|
/** Snapshot memory and disk. Sandbox.connect (router.get) resumes it. */
|
|
63
140
|
async pause() {
|
|
64
141
|
await this.sbx.pause();
|
|
65
142
|
}
|
|
66
|
-
|
|
143
|
+
}
|
|
144
|
+
/** Friendly names models send that xdotool doesn't know; anything else passes through verbatim. */
|
|
145
|
+
const KEY_ALIASES = {
|
|
146
|
+
enter: "Return",
|
|
147
|
+
return: "Return",
|
|
148
|
+
esc: "Escape",
|
|
149
|
+
escape: "Escape",
|
|
150
|
+
backspace: "BackSpace",
|
|
151
|
+
del: "Delete",
|
|
152
|
+
delete: "Delete",
|
|
153
|
+
tab: "Tab",
|
|
154
|
+
space: "space",
|
|
155
|
+
up: "Up",
|
|
156
|
+
down: "Down",
|
|
157
|
+
left: "Left",
|
|
158
|
+
right: "Right",
|
|
159
|
+
home: "Home",
|
|
160
|
+
end: "End",
|
|
161
|
+
pageup: "Page_Up",
|
|
162
|
+
page_up: "Page_Up",
|
|
163
|
+
pagedown: "Page_Down",
|
|
164
|
+
page_down: "Page_Down",
|
|
165
|
+
cmd: "super",
|
|
166
|
+
command: "super",
|
|
167
|
+
win: "super",
|
|
168
|
+
windows: "super",
|
|
169
|
+
meta: "super",
|
|
170
|
+
control: "ctrl",
|
|
171
|
+
option: "alt",
|
|
172
|
+
};
|
|
173
|
+
/** "ctrl+Return" -> "ctrl+Return"; "Enter" -> "Return". Space-separated combos stay separate. */
|
|
174
|
+
function xdoKeys(text) {
|
|
175
|
+
return text
|
|
176
|
+
.trim()
|
|
177
|
+
.split(/\s+/)
|
|
178
|
+
.filter(Boolean)
|
|
179
|
+
.map((combo) => combo
|
|
180
|
+
.split("+")
|
|
181
|
+
.map((k) => KEY_ALIASES[k.toLowerCase()] ?? k)
|
|
182
|
+
.join("+"));
|
|
183
|
+
}
|
|
184
|
+
function sh(s) {
|
|
185
|
+
return `'${s.replace(/'/g, `'"'"'`)}'`;
|
|
186
|
+
}
|
|
187
|
+
/** Width and height from a PNG's IHDR chunk. */
|
|
188
|
+
function pngSize(png) {
|
|
189
|
+
if (png.length < 24 || png[0] !== 0x89 || png[1] !== 0x50)
|
|
190
|
+
return {};
|
|
191
|
+
const v = new DataView(png.buffer, png.byteOffset, png.byteLength);
|
|
192
|
+
return { width: v.getUint32(16), height: v.getUint32(20) };
|
|
193
|
+
}
|
|
194
|
+
function errMessage(err) {
|
|
195
|
+
return err?.stderr?.trim() || err?.message || String(err);
|
|
196
|
+
}
|
|
197
|
+
class ActionError extends Error {
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* An E2B desktop sandbox. Canonical actions map to `@e2b/desktop` calls where the SDK has one, and
|
|
201
|
+
* to xdotool/scrot commands where it doesn't (triple click, horizontal scroll, hold_key, zoom).
|
|
202
|
+
*/
|
|
203
|
+
class E2BComputer {
|
|
204
|
+
sbx;
|
|
205
|
+
nativeId;
|
|
206
|
+
frame = 0;
|
|
207
|
+
streamKey;
|
|
208
|
+
constructor(sbx) {
|
|
209
|
+
this.sbx = sbx;
|
|
210
|
+
this.nativeId = sbx.sandboxId;
|
|
211
|
+
}
|
|
212
|
+
async info() {
|
|
213
|
+
const i = await this.sbx.getInfo();
|
|
214
|
+
const running = i.state === "running";
|
|
215
|
+
return {
|
|
216
|
+
id: this.nativeId,
|
|
217
|
+
os: "linux",
|
|
218
|
+
status: running ? "ready" : "sleeping",
|
|
219
|
+
screen: running ? await this.sbx.getScreenSize().catch(() => undefined) : undefined,
|
|
220
|
+
externalUserId: i.metadata?.externalUserId ?? null,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
async act(action) {
|
|
224
|
+
try {
|
|
225
|
+
const extra = await this.perform(action);
|
|
226
|
+
if (action.action === "screenshot" || action.action === "zoom" || !action.noScreenshot) {
|
|
227
|
+
const shot = extra.image ?? (await this.sbx.screenshot());
|
|
228
|
+
delete extra.image;
|
|
229
|
+
return { action: action.action, ok: true, ...extra, ...this.frameOf(shot) };
|
|
230
|
+
}
|
|
231
|
+
delete extra.image;
|
|
232
|
+
return { action: action.action, ok: true, ...extra };
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
return { action: action.action, ok: false, error: errMessage(err) };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** Runs in order and stops at the first failed action (later coordinates may no longer apply). */
|
|
239
|
+
async actBatch(actions) {
|
|
240
|
+
const out = [];
|
|
241
|
+
for (const a of actions) {
|
|
242
|
+
const r = await this.act(a);
|
|
243
|
+
out.push(r);
|
|
244
|
+
if (!r.ok)
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
frameOf(png) {
|
|
250
|
+
return { frameId: ++this.frame, imageB64: Buffer.from(png).toString("base64"), mime: "image/png", ...pngSize(png) };
|
|
251
|
+
}
|
|
252
|
+
/** Run a desktop command, with an explicit timeout rather than the SDK's 60 s default. */
|
|
253
|
+
run(cmd, timeoutSeconds = 60) {
|
|
254
|
+
return this.sbx.commands.run(cmd, { timeoutMs: timeoutSeconds * 1000 });
|
|
255
|
+
}
|
|
256
|
+
async withModifier(modifier, fn) {
|
|
257
|
+
if (!modifier)
|
|
258
|
+
return fn();
|
|
259
|
+
const keys = xdoKeys(modifier.replace(/\s+/g, "+"))[0]?.split("+") ?? [];
|
|
260
|
+
await this.run(`xdotool keydown ${keys.map(sh).join(" ")}`);
|
|
261
|
+
try {
|
|
262
|
+
return await fn();
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
await this.run(`xdotool keyup ${[...keys].reverse().map(sh).join(" ")}`).catch(() => { });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async moveTo(a, required) {
|
|
269
|
+
if (!a.coordinate) {
|
|
270
|
+
if (required)
|
|
271
|
+
throw new ActionError(`${a.action} needs a coordinate`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const [x, y] = a.coordinate;
|
|
275
|
+
// The SDK's click helpers skip the move when x or y is 0, so always move explicitly.
|
|
276
|
+
await this.sbx.moveMouse(Math.round(x), Math.round(y));
|
|
277
|
+
}
|
|
278
|
+
/** Does the action; returns extra result fields (and `image` for actions that produce their own). */
|
|
279
|
+
async perform(a) {
|
|
280
|
+
const click = async (fn) => {
|
|
281
|
+
await this.moveTo(a, false);
|
|
282
|
+
await this.withModifier(a.modifier, async () => {
|
|
283
|
+
for (let i = 0; i < Math.max(1, a.repeat ?? 1); i++)
|
|
284
|
+
await fn();
|
|
285
|
+
});
|
|
286
|
+
return {};
|
|
287
|
+
};
|
|
288
|
+
switch (a.action) {
|
|
289
|
+
case "screenshot":
|
|
290
|
+
return {};
|
|
291
|
+
case "left_click":
|
|
292
|
+
return click(() => this.sbx.leftClick());
|
|
293
|
+
case "right_click":
|
|
294
|
+
return click(() => this.sbx.rightClick());
|
|
295
|
+
case "middle_click":
|
|
296
|
+
return click(() => this.sbx.middleClick());
|
|
297
|
+
case "double_click":
|
|
298
|
+
return click(() => this.sbx.doubleClick());
|
|
299
|
+
case "triple_click":
|
|
300
|
+
return click(() => this.run("xdotool click --repeat 3 1"));
|
|
301
|
+
case "mouse_move":
|
|
302
|
+
await this.moveTo(a, true);
|
|
303
|
+
return {};
|
|
304
|
+
case "left_click_drag": {
|
|
305
|
+
const to = a.coordinate;
|
|
306
|
+
if (!to)
|
|
307
|
+
throw new ActionError("left_click_drag needs a coordinate");
|
|
308
|
+
let from = a.startCoordinate;
|
|
309
|
+
if (!from) {
|
|
310
|
+
const p = await this.sbx.getCursorPosition();
|
|
311
|
+
from = [p.x, p.y];
|
|
312
|
+
}
|
|
313
|
+
const start = [Math.round(from[0]), Math.round(from[1])];
|
|
314
|
+
const end = [Math.round(to[0]), Math.round(to[1])];
|
|
315
|
+
await this.withModifier(a.modifier, () => this.sbx.drag(start, end));
|
|
316
|
+
return {};
|
|
317
|
+
}
|
|
318
|
+
case "left_mouse_down":
|
|
319
|
+
await this.moveTo(a, false);
|
|
320
|
+
await this.sbx.mousePress("left");
|
|
321
|
+
return {};
|
|
322
|
+
case "left_mouse_up":
|
|
323
|
+
await this.moveTo(a, false);
|
|
324
|
+
await this.sbx.mouseRelease("left");
|
|
325
|
+
return {};
|
|
326
|
+
case "scroll": {
|
|
327
|
+
const dir = a.scrollDirection ?? "down";
|
|
328
|
+
const amount = Math.max(1, Math.round(a.scrollAmount ?? 3));
|
|
329
|
+
await this.moveTo(a, false);
|
|
330
|
+
await this.withModifier(a.modifier, async () => {
|
|
331
|
+
if (dir === "up" || dir === "down")
|
|
332
|
+
await this.sbx.scroll(dir, amount);
|
|
333
|
+
else
|
|
334
|
+
await this.run(`xdotool click --repeat ${amount} ${dir === "left" ? 6 : 7}`);
|
|
335
|
+
});
|
|
336
|
+
return {};
|
|
337
|
+
}
|
|
338
|
+
case "type":
|
|
339
|
+
if (a.text === undefined)
|
|
340
|
+
throw new ActionError("type needs text");
|
|
341
|
+
await this.sbx.write(a.text);
|
|
342
|
+
return {};
|
|
343
|
+
case "key": {
|
|
344
|
+
if (!a.text)
|
|
345
|
+
throw new ActionError("key needs text, e.g. \"Return\" or \"ctrl+s\"");
|
|
346
|
+
const repeat = Math.max(1, Math.round(a.repeat ?? 1));
|
|
347
|
+
await this.run(`xdotool key${repeat > 1 ? ` --repeat ${repeat}` : ""} -- ${xdoKeys(a.text).map(sh).join(" ")}`);
|
|
348
|
+
return {};
|
|
349
|
+
}
|
|
350
|
+
case "hold_key": {
|
|
351
|
+
if (!a.text)
|
|
352
|
+
throw new ActionError("hold_key needs text");
|
|
353
|
+
const keys = xdoKeys(a.text).map(sh).join(" ");
|
|
354
|
+
const secs = a.duration ?? 1;
|
|
355
|
+
if (!(secs >= 0 && secs <= 100))
|
|
356
|
+
throw new ActionError("duration must be 0 to 100 seconds");
|
|
357
|
+
// The SDK's default command timeout is 60 s and would end a longer hold with the key still down.
|
|
358
|
+
await this.run(`xdotool keydown ${keys}; sleep ${secs}; xdotool keyup ${keys}`, secs + 30);
|
|
359
|
+
return {};
|
|
360
|
+
}
|
|
361
|
+
case "wait":
|
|
362
|
+
await new Promise((r) => setTimeout(r, Math.max(0, a.duration ?? 1) * 1000));
|
|
363
|
+
return {};
|
|
364
|
+
case "zoom": {
|
|
365
|
+
if (!a.region)
|
|
366
|
+
throw new ActionError("zoom needs a region [x0, y0, x1, y1]");
|
|
367
|
+
const [x0, y0, x1, y1] = a.region.map((n) => Math.round(n));
|
|
368
|
+
if (x1 <= x0 || y1 <= y0 || x0 < 0 || y0 < 0)
|
|
369
|
+
throw new ActionError(`bad zoom region ${JSON.stringify(a.region)}`);
|
|
370
|
+
return { image: await this.capture(`-a ${x0},${y0},${x1 - x0},${y1 - y0}`, "png"), region: [x0, y0, x1, y1] };
|
|
371
|
+
}
|
|
372
|
+
case "cursor_position": {
|
|
373
|
+
const p = await this.sbx.getCursorPosition();
|
|
374
|
+
return { x: p.x, y: p.y, coordinate: [p.x, p.y] };
|
|
375
|
+
}
|
|
376
|
+
default:
|
|
377
|
+
throw new ActionError(`e2b does not support the "${a.action}" action`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
/** scrot straight to a temp file (format from the extension), read it back, clean up. */
|
|
381
|
+
async capture(flags, ext) {
|
|
382
|
+
const path = `/tmp/sbr-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
|
|
383
|
+
await this.run(`scrot ${flags} ${path}`);
|
|
384
|
+
const bytes = await this.sbx.files.read(path, { format: "bytes" });
|
|
385
|
+
this.sbx.files.remove(path).catch(() => { });
|
|
386
|
+
return bytes;
|
|
387
|
+
}
|
|
388
|
+
async screenshot(opts = {}) {
|
|
389
|
+
if (opts.format === "jpeg")
|
|
390
|
+
return this.capture(`--pointer -q ${Math.round(opts.quality ?? 75)}`, "jpg");
|
|
391
|
+
return await this.sbx.screenshot();
|
|
392
|
+
}
|
|
393
|
+
async exec(command, timeoutSeconds = 60) {
|
|
394
|
+
try {
|
|
395
|
+
const r = await this.sbx.commands.run(command, { timeoutMs: timeoutSeconds * 1000 });
|
|
396
|
+
return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr };
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
// CommandExitError for a nonzero exit is a result, not a failure.
|
|
400
|
+
if (err?.name === "CommandExitError" || typeof err?.exitCode === "number") {
|
|
401
|
+
return { exitCode: err.exitCode, stdout: err.stdout ?? "", stderr: err.stderr ?? "" };
|
|
402
|
+
}
|
|
403
|
+
throw new ProviderError("e2b", err?.message ?? String(err));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
async readFile(path) {
|
|
407
|
+
return await this.sbx.files.read(path, { format: "bytes" });
|
|
408
|
+
}
|
|
409
|
+
async writeFile(path, content) {
|
|
410
|
+
await this.sbx.files.write(path, toBytes(content));
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* A noVNC link to the desktop, password in the URL, valid while the sandbox lives (`ttlSeconds`
|
|
414
|
+
* is not supported, so `expiresAt` is null). `watch` sets noVNC's view_only flag, which is
|
|
415
|
+
* enforced by the client page only: whoever holds a watch link can edit it into a control link.
|
|
416
|
+
* The first call from a fresh connection restarts the VNC server with a new password, which
|
|
417
|
+
* invalidates links handed out earlier.
|
|
418
|
+
*/
|
|
419
|
+
async viewer(mode = "watch", _opts = {}) {
|
|
420
|
+
if (!this.streamKey) {
|
|
421
|
+
// After connect/resume the SDK has lost the stream state; restart x11vnc so we know the key.
|
|
422
|
+
await this.sbx.stream.stop().catch(() => { });
|
|
423
|
+
await this.sbx.stream.start({ requireAuth: true });
|
|
424
|
+
this.streamKey = this.sbx.stream.getAuthKey();
|
|
425
|
+
}
|
|
426
|
+
const url = this.sbx.stream.getUrl({ autoConnect: true, viewOnly: mode === "watch", resize: "scale", authKey: this.streamKey });
|
|
427
|
+
return { url, expiresAt: null };
|
|
428
|
+
}
|
|
429
|
+
/** E2B pause: memory and disk are snapshotted. connectComputer resumes it; open viewers drop. */
|
|
430
|
+
async sleep() {
|
|
431
|
+
await this.sbx.pause();
|
|
432
|
+
this.streamKey = undefined;
|
|
433
|
+
}
|
|
434
|
+
async delete() {
|
|
67
435
|
await this.sbx.kill();
|
|
68
436
|
}
|
|
69
437
|
}
|
package/dist/providers/exedev.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { ProviderError } from "../types.js";
|
|
3
|
-
import { dirname, shq, toBytes, wrapCommand } from "../util.js";
|
|
3
|
+
import { dirname, killedByTimeout, shq, timeoutError, toBytes, wrapCommand } from "../util.js";
|
|
4
4
|
/**
|
|
5
5
|
* exe.dev standalone VMs over the HTTPS API (no SDK needed).
|
|
6
6
|
* Everything is one endpoint: POST https://exe.dev/exec, Authorization: Bearer exe1..., body = the
|
|
@@ -110,7 +110,8 @@ export class ExeDevProvider {
|
|
|
110
110
|
flags.push(`--image=${image}`);
|
|
111
111
|
}
|
|
112
112
|
const vm = await this.lobby(`new ${flags.join(" ")}`);
|
|
113
|
-
const
|
|
113
|
+
const vmName = vm.vm_name ?? name;
|
|
114
|
+
const box = new ExeDevSandbox(this, vmName, spec.env);
|
|
114
115
|
try {
|
|
115
116
|
await box.waitForSsh();
|
|
116
117
|
}
|
|
@@ -118,6 +119,11 @@ export class ExeDevProvider {
|
|
|
118
119
|
await box.kill().catch(() => { });
|
|
119
120
|
throw err;
|
|
120
121
|
}
|
|
122
|
+
// exe.dev has no lifetime setting, so the VM powers itself off when spec.timeoutMinutes runs out
|
|
123
|
+
// (a stopped VM bills disk only; kill() or `sandboxrouter gc` deletes it). Best effort: it needs
|
|
124
|
+
// root or passwordless sudo in the image (not yet confirmed on a real exe.dev VM).
|
|
125
|
+
const secs = Math.max(60, Math.round((spec.timeoutMinutes ?? 30) * 60));
|
|
126
|
+
await this.ssh(vmName, `setsid nohup sh -c 'sleep ${secs}; sudo -n poweroff || poweroff' > /dev/null 2>&1 < /dev/null &`).catch(() => { });
|
|
121
127
|
return box;
|
|
122
128
|
}
|
|
123
129
|
async connect(nativeId) {
|
|
@@ -214,11 +220,17 @@ class ExeDevSandbox {
|
|
|
214
220
|
stderr += e;
|
|
215
221
|
opts.onStderr?.(e);
|
|
216
222
|
}
|
|
217
|
-
if (!running)
|
|
218
|
-
|
|
223
|
+
if (!running) {
|
|
224
|
+
const durationMs = Date.now() - t0;
|
|
225
|
+
// `timeout -k 5` on the VM leaves exit 124 (or 137 after SIGKILL) when it had to stop the command.
|
|
226
|
+
if (killedByTimeout(r.exitCode, durationMs, timeout))
|
|
227
|
+
throw timeoutError(ID, timeout);
|
|
228
|
+
return { exitCode: r.exitCode, stdout, stderr, durationMs };
|
|
229
|
+
}
|
|
219
230
|
if (Date.now() > deadline) {
|
|
231
|
+
// The VM's own `timeout` should have ended it a minute ago: stop it from here as a last resort.
|
|
220
232
|
await this.p.ssh(this.nativeId, `pkill -f ${shq(`${dir}/cmd`)}; rm -rf ${dir}`).catch(() => { });
|
|
221
|
-
throw
|
|
233
|
+
throw timeoutError(ID, timeout);
|
|
222
234
|
}
|
|
223
235
|
script = report(outOff, errOff);
|
|
224
236
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Freestyle's Ubuntu base snapshots (hardware is baked into each), from
|
|
4
|
+
* https://www.freestyle.sh/docs/vms/base-snapshots, priced at the list rates on
|
|
5
|
+
* https://www.freestyle.sh/docs/vms/pricing-and-limits ($0.04032/vCPU-h, $0.0129/GiB-h memory,
|
|
6
|
+
* $0.000086/GiB-h disk), checked 2026-11-04. Free tops out at `ubuntu`, Hobby at `ubuntu-lg`,
|
|
7
|
+
* Pro at `ubuntu-2xl`.
|
|
8
|
+
*/
|
|
9
|
+
export declare const FREESTYLE_SIZES: {
|
|
10
|
+
snapshot: string;
|
|
11
|
+
vcpu: number;
|
|
12
|
+
memoryGb: number;
|
|
13
|
+
diskGb: number;
|
|
14
|
+
usdHour: number;
|
|
15
|
+
}[];
|
|
16
|
+
/** Smallest Ubuntu base snapshot with at least the requested vCPU and memory, or undefined. */
|
|
17
|
+
export declare function freestyleSize(vcpu: number, memoryGb: number): (typeof FREESTYLE_SIZES)[number] | undefined;
|
|
18
|
+
export declare class FreestyleProvider implements Provider {
|
|
19
|
+
private apiKey;
|
|
20
|
+
private baseUrl;
|
|
21
|
+
/** How often to poll a backgrounded request or a booting VM. */
|
|
22
|
+
private pollMs;
|
|
23
|
+
readonly id: "freestyle";
|
|
24
|
+
constructor(apiKey?: string | undefined, baseUrl?: string,
|
|
25
|
+
/** How often to poll a backgrounded request or a booting VM. */
|
|
26
|
+
pollMs?: number);
|
|
27
|
+
configured(): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* @internal One API call. With `background`, a call still running after 5 s comes back as 202
|
|
30
|
+
* and is polled at /v5/background-requests/:id until its own response is ready.
|
|
31
|
+
*/
|
|
32
|
+
request(method: string, path: string, init?: {
|
|
33
|
+
json?: unknown;
|
|
34
|
+
bytes?: Uint8Array;
|
|
35
|
+
background?: boolean;
|
|
36
|
+
signal?: AbortSignal;
|
|
37
|
+
}): Promise<Response>;
|
|
38
|
+
/** @internal JSON call. */
|
|
39
|
+
json<T>(method: string, path: string, init?: {
|
|
40
|
+
json?: unknown;
|
|
41
|
+
background?: boolean;
|
|
42
|
+
signal?: AbortSignal;
|
|
43
|
+
}): Promise<T>;
|
|
44
|
+
create(spec: SandboxSpec): Promise<ProviderSandbox>;
|
|
45
|
+
/** Reattach by VM id (or slug); a paused or stopped VM is started again. */
|
|
46
|
+
connect(nativeId: string): Promise<ProviderSandbox>;
|
|
47
|
+
/** @internal */
|
|
48
|
+
get poll(): number;
|
|
49
|
+
}
|