withruntime 0.11.0 → 0.11.2
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/BLAXEL.md +8 -9
- package/DAYTONA.md +8 -9
- package/E2B.md +63 -21
- package/README.md +6 -5
- package/README.src.md +6 -5
- package/VERCEL.md +3 -2
- package/compatibility-lock.json +69 -14
- package/dist/api-defaults.d.ts +1 -1
- package/dist/api-defaults.js +1 -1
- package/dist/blaxel/errors.js +4 -4
- package/dist/blaxel/lookup.d.ts +2 -1
- package/dist/blaxel/lookup.js +2 -1
- package/dist/blaxel/sandbox.d.ts +8 -7
- package/dist/blaxel/sandbox.js +16 -12
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +99 -27
- package/dist/client.d.ts +16 -60
- package/dist/client.js +14 -103
- package/dist/compat/files.js +5 -1
- package/dist/compat/lease.d.ts +1 -0
- package/dist/compat/lease.js +14 -8
- package/dist/credentials.d.ts +9 -0
- package/dist/credentials.js +26 -4
- package/dist/daytona/daytona.d.ts +1 -2
- package/dist/daytona/daytona.js +30 -14
- package/dist/daytona/sandbox.d.ts +9 -4
- package/dist/daytona/sandbox.js +24 -5
- package/dist/e2b/client.d.ts +15 -2
- package/dist/e2b/client.js +61 -7
- package/dist/e2b/commands.d.ts +3 -0
- package/dist/e2b/commands.js +16 -5
- package/dist/e2b/errors.d.ts +26 -2
- package/dist/e2b/errors.js +38 -5
- package/dist/e2b/filesystem.d.ts +1 -1
- package/dist/e2b/filesystem.js +82 -16
- package/dist/e2b/index.d.ts +2 -2
- package/dist/e2b/index.js +2 -2
- package/dist/e2b/sandbox.d.ts +25 -12
- package/dist/e2b/sandbox.js +196 -165
- package/dist/e2b/unsupported.d.ts +19 -0
- package/dist/e2b/unsupported.js +21 -0
- package/dist/e2b/users.d.ts +9 -0
- package/dist/e2b/users.js +22 -0
- package/dist/errors.d.ts +10 -4
- package/dist/errors.js +18 -10
- package/dist/identity.js +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/machine.d.ts +180 -0
- package/dist/machine.js +504 -0
- package/dist/modal/index.js +6 -2
- package/dist/network-cli.js +7 -7
- package/dist/openai-agents/index.d.ts +1 -1
- package/dist/openai-agents/index.js +1 -1
- package/dist/products/browser.d.ts +2 -1
- package/dist/products/browser.js +9 -19
- package/dist/products/desktop.d.ts +2 -1
- package/dist/products/desktop.js +5 -30
- package/dist/products/jobs.d.ts +6 -5
- package/dist/products/jobs.js +6 -3
- package/dist/products/limits.d.ts +4 -10
- package/dist/products/mcp.d.ts +5 -1
- package/dist/products/mcp.js +29 -8
- package/dist/products/network-products.d.ts +1 -1
- package/dist/products/network-products.js +1 -1
- package/dist/products/network.d.ts +1 -1
- package/dist/products/observability.d.ts +1 -1
- package/dist/products/private-network.d.ts +1 -1
- package/dist/products/secrets.d.ts +3 -3
- package/dist/products/switching.d.ts +2 -3
- package/dist/runloop/index.js +6 -2
- package/dist/sandbox.d.ts +7 -84
- package/dist/sandbox.js +58 -345
- package/dist/tar.d.ts +2 -0
- package/dist/tar.js +74 -16
- package/dist/transport.d.ts +36 -8
- package/dist/transport.js +174 -37
- package/dist/types.d.ts +102 -12
- package/dist/vercel/filesystem.js +3 -1
- package/dist/vercel/sandbox.d.ts +18 -12
- package/dist/vercel/sandbox.js +27 -15
- package/dist/wait.d.ts +12 -0
- package/dist/wait.js +47 -0
- package/package.json +1 -1
package/dist/machine.js
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
/* The machine engine's client (CLOUD.md section 2, engines/machine/ENGINE.md):
|
|
2
|
+
what every product that runs code shares, written once. A product is a
|
|
3
|
+
setting of the engine, here the path the API serves it under, and its own
|
|
4
|
+
class adds what only it does: `Sandbox` extends `Machine` with commands,
|
|
5
|
+
files and processes, and `Sandboxes` extends `Machines`. Sandboxes are the
|
|
6
|
+
only machine product today; a second one is a new subclass, not a copy.
|
|
7
|
+
Nothing here is exported from the package: its public surface is the
|
|
8
|
+
product classes. */
|
|
9
|
+
import { KEEP_ALIVE_MARGIN_SECONDS, WAIT_FOR_TIMEOUT_SECONDS } from "./api-defaults.js";
|
|
10
|
+
import { RuntimeError } from "./errors.js";
|
|
11
|
+
import { Page } from "./page.js";
|
|
12
|
+
/** How long snapshot() waits, pause and capture together, unless told. */
|
|
13
|
+
const SNAPSHOT_DEADLINE_MS = 10 * 60_000;
|
|
14
|
+
const enc = (id) => encodeURIComponent(id);
|
|
15
|
+
/** Any other call's request options, as the caller gave them: `timeoutMs` is
|
|
16
|
+
* the call's whole deadline, and 0 turns it off. */
|
|
17
|
+
export function requestOnly(options) {
|
|
18
|
+
return {
|
|
19
|
+
...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
|
|
20
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
21
|
+
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** DELETE /v1/<plural>/{id}, for `machine.delete()` and the product's
|
|
25
|
+
* `delete(id)`. */
|
|
26
|
+
export function deleteMachine(product, t, id, options = {}) {
|
|
27
|
+
return t.json({
|
|
28
|
+
method: "DELETE",
|
|
29
|
+
path: `/v1/${product.plural}/${enc(id)}`,
|
|
30
|
+
...options,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/** One page of a product's list, and the pages after it. */
|
|
34
|
+
export function machinePage(product, t, wrap, body, query, options = {}) {
|
|
35
|
+
return new Page(body.data.map((info) => wrap(t, info)), body.nextCursor, async (cursor) => machinePage(product, t, wrap, await t.json({
|
|
36
|
+
method: "GET",
|
|
37
|
+
path: `/v1/${product.plural}`,
|
|
38
|
+
query: Object.assign({}, query, { cursor }),
|
|
39
|
+
...options,
|
|
40
|
+
}), query, options));
|
|
41
|
+
}
|
|
42
|
+
/** One machine: its record and the verbs every machine product shares. */
|
|
43
|
+
export class Machine {
|
|
44
|
+
#info;
|
|
45
|
+
#keepAlive;
|
|
46
|
+
#t;
|
|
47
|
+
#product;
|
|
48
|
+
constructor(product, transport, info) {
|
|
49
|
+
this.#product = product;
|
|
50
|
+
this.#t = transport;
|
|
51
|
+
this.#info = info;
|
|
52
|
+
}
|
|
53
|
+
get id() {
|
|
54
|
+
return this.#info.id;
|
|
55
|
+
}
|
|
56
|
+
/** The API's latest answer. `start`, the report of an image's start
|
|
57
|
+
* command, is the create's alone: the API keeps no record of it, so a
|
|
58
|
+
* later read of this sandbox keeps it here. */
|
|
59
|
+
#keep(info) {
|
|
60
|
+
const start = this.#info?.start;
|
|
61
|
+
this.#info = info.start === undefined && start !== undefined ? { ...info, start } : info;
|
|
62
|
+
}
|
|
63
|
+
#path(suffix = "") {
|
|
64
|
+
return `/v1/${this.#product.plural}/${enc(this.id)}${suffix}`;
|
|
65
|
+
}
|
|
66
|
+
/** What the API last said about this sandbox. `refresh()` asks again. */
|
|
67
|
+
get info() {
|
|
68
|
+
return this.#info;
|
|
69
|
+
}
|
|
70
|
+
get state() {
|
|
71
|
+
return this.#info.state;
|
|
72
|
+
}
|
|
73
|
+
async refresh(options = {}) {
|
|
74
|
+
this.#keep(await this.#t.json({
|
|
75
|
+
method: "GET",
|
|
76
|
+
path: this.#path(),
|
|
77
|
+
...options,
|
|
78
|
+
}));
|
|
79
|
+
return this;
|
|
80
|
+
}
|
|
81
|
+
/** Waits (server-side, no polling) until the sandbox reaches `state`. */
|
|
82
|
+
async waitFor(state, options = {}) {
|
|
83
|
+
this.#keep(await this.#t.json({
|
|
84
|
+
method: "GET",
|
|
85
|
+
path: this.#path(),
|
|
86
|
+
query: {
|
|
87
|
+
waitFor: state,
|
|
88
|
+
timeoutSeconds: options.timeoutSeconds ?? WAIT_FOR_TIMEOUT_SECONDS,
|
|
89
|
+
},
|
|
90
|
+
...options,
|
|
91
|
+
}));
|
|
92
|
+
return this;
|
|
93
|
+
}
|
|
94
|
+
async stop(options = {}) {
|
|
95
|
+
this.#keepAlive?.();
|
|
96
|
+
return this.#lifecycle("stop", options);
|
|
97
|
+
}
|
|
98
|
+
async pause(options = {}) {
|
|
99
|
+
return this.#lifecycle("pause", options);
|
|
100
|
+
}
|
|
101
|
+
/** Deletes it for good: stops it if it runs or is paused, deletes its disk
|
|
102
|
+
* and paused memory, revokes its previews and ports, and removes it from
|
|
103
|
+
* lists. Its snapshots, usage and audit entries stay. Deleting it again
|
|
104
|
+
* answers the same. */
|
|
105
|
+
async delete(options = {}) {
|
|
106
|
+
this.#keepAlive?.();
|
|
107
|
+
return deleteMachine(this.#product, this.#t, this.id, options);
|
|
108
|
+
}
|
|
109
|
+
/** Carries on a paused sandbox; for one with a time limit,
|
|
110
|
+
* `timeoutSeconds` is its new one. */
|
|
111
|
+
async wake(options = {}) {
|
|
112
|
+
const { timeoutSeconds, ...rest } = options;
|
|
113
|
+
try {
|
|
114
|
+
return await this.#lifecycle("wake", rest, timeoutSeconds === undefined ? {} : { timeoutSeconds });
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
// Waking one that is already awake is done, not a mistake. With a new
|
|
118
|
+
// lease asked for, the refusal stands: it was not given.
|
|
119
|
+
if (!(error instanceof RuntimeError) || error.code !== "not_paused" || timeoutSeconds)
|
|
120
|
+
throw error;
|
|
121
|
+
await this.refresh(requestOnly(rest));
|
|
122
|
+
if (this.state !== "running" && this.state !== "starting")
|
|
123
|
+
throw error;
|
|
124
|
+
return this;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** More time before its time limit ends (at most an hour ahead of now). A
|
|
128
|
+
* sandbox with no time limit needs none: the call answers at once and
|
|
129
|
+
* changes nothing. */
|
|
130
|
+
async extend(seconds, options = {}) {
|
|
131
|
+
return this.#lifecycle("extend", { ...options, wait: false }, { seconds });
|
|
132
|
+
}
|
|
133
|
+
/** Changes its name, labels, automatic wake, idle pause or persistence;
|
|
134
|
+
* fields left out stay as they are. `persistent: true` keeps it running
|
|
135
|
+
* while credit lasts (paid only). */
|
|
136
|
+
async update(settings, options = {}) {
|
|
137
|
+
return this.#lifecycle("update", { ...options, wait: false }, { ...settings });
|
|
138
|
+
}
|
|
139
|
+
/** Moves it to another image (id, name, name:tag or name@version), keeping
|
|
140
|
+
* its id, /workspace (its home: dotfiles, pip --user and npm -g installs),
|
|
141
|
+
* volumes, environment, name and previews. Its processes restart, and
|
|
142
|
+
* everything else on its old disk (sudo installs, apt packages, /etc) is
|
|
143
|
+
* lost; snapshot it first to keep everything. A running sandbox is paused
|
|
144
|
+
* first. A switch that fails is undone (`switch_undone`), the sandbox on
|
|
145
|
+
* its old image with nothing lost. Charged as a wake. `keep` is optional:
|
|
146
|
+
* "workspace" is the only value, and it is sent either way, so an API from
|
|
147
|
+
* before it was optional accepts the call too. */
|
|
148
|
+
async switchImage(image, options = {}) {
|
|
149
|
+
const { keep = "workspace", ...rest } = options;
|
|
150
|
+
this.#keep(await this.#t.json({
|
|
151
|
+
method: "POST",
|
|
152
|
+
path: this.#path(":switch-image"),
|
|
153
|
+
body: { image, keep },
|
|
154
|
+
// A pause, two boots and the copy between them.
|
|
155
|
+
wait: 120,
|
|
156
|
+
...requestOnly(rest),
|
|
157
|
+
}));
|
|
158
|
+
return this;
|
|
159
|
+
}
|
|
160
|
+
/** Gives it more or fewer vCPUs or more or less memory by a restart: its id,
|
|
161
|
+
* whole disk, volumes, environment, name and previews stay, and its
|
|
162
|
+
* programs stop (snapshot it first to keep its memory too). A running sandbox is paused, or stopped if it is
|
|
163
|
+
* persistent, and comes back running at the new size on the same server; a
|
|
164
|
+
* paused or stopped one is started. Memory bills on the new size from then.
|
|
165
|
+
* Refused, with nothing changed, when the server has no room
|
|
166
|
+
* (`no_capacity`), above your quota or the 2 vCPU and 4 GiB without credit, or
|
|
167
|
+
* while a snapshot or fork of it is being taken. `restart` is optional:
|
|
168
|
+
* true is the only value, and it is sent either way, so an API from before
|
|
169
|
+
* it was optional accepts the call too. */
|
|
170
|
+
async resize(size, options = {}) {
|
|
171
|
+
const { restart = true, ...rest } = options;
|
|
172
|
+
this.#keep(await this.#t.json({
|
|
173
|
+
method: "POST",
|
|
174
|
+
path: this.#path(":resize"),
|
|
175
|
+
body: { ...size, restart },
|
|
176
|
+
// A pause or stop and a cold boot.
|
|
177
|
+
wait: 120,
|
|
178
|
+
...requestOnly(rest),
|
|
179
|
+
}));
|
|
180
|
+
return this;
|
|
181
|
+
}
|
|
182
|
+
/** Keeps a sandbox with a time limit running past it, in the background,
|
|
183
|
+
* until stop() or the returned function ends it: every `everySeconds` (60)
|
|
184
|
+
* it extends the limit so that `marginSeconds` (600) remain, never more
|
|
185
|
+
* than the hour ahead the API allows. A sandbox with no time limit
|
|
186
|
+
* (`endsAt` null) needs none, and is only watched. Running time is billed
|
|
187
|
+
* as it is used. A paused sandbox is left paused (a request wakes it,
|
|
188
|
+
* unless autoWake is off); a stopped one ends the loop. It does not keep a
|
|
189
|
+
* Node or Bun process alive by itself. */
|
|
190
|
+
keepAlive(options = {}) {
|
|
191
|
+
this.#keepAlive?.();
|
|
192
|
+
const every = Math.max(10, options.everySeconds ?? 60) * 1000;
|
|
193
|
+
const margin = Math.min(3600, Math.max(60, options.marginSeconds ?? KEEP_ALIVE_MARGIN_SECONDS));
|
|
194
|
+
let ended = false;
|
|
195
|
+
let timer;
|
|
196
|
+
const end = () => {
|
|
197
|
+
ended = true;
|
|
198
|
+
if (timer)
|
|
199
|
+
clearTimeout(timer);
|
|
200
|
+
if (this.#keepAlive === end)
|
|
201
|
+
this.#keepAlive = undefined;
|
|
202
|
+
};
|
|
203
|
+
const tick = async () => {
|
|
204
|
+
if (ended)
|
|
205
|
+
return;
|
|
206
|
+
try {
|
|
207
|
+
await this.refresh();
|
|
208
|
+
if (this.state === "stopped" || this.state === "stopping")
|
|
209
|
+
return end();
|
|
210
|
+
// No time limit: nothing to extend (an older server sends no endsAt).
|
|
211
|
+
if (this.state === "running" && this.info.endsAt !== null) {
|
|
212
|
+
const left = (Date.parse(this.info.endsAt ?? this.info.expiresAt) - Date.now()) / 1000;
|
|
213
|
+
const need = Math.ceil(margin - left);
|
|
214
|
+
if (need >= 1)
|
|
215
|
+
await this.extend(Math.min(3600, need));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
options.onError?.(error);
|
|
220
|
+
}
|
|
221
|
+
if (ended)
|
|
222
|
+
return;
|
|
223
|
+
timer = setTimeout(() => void tick(), every);
|
|
224
|
+
timer.unref?.();
|
|
225
|
+
};
|
|
226
|
+
this.#keepAlive = end;
|
|
227
|
+
void tick();
|
|
228
|
+
return end;
|
|
229
|
+
}
|
|
230
|
+
/** Days a paused sandbox is kept before deletion (1 to 365). */
|
|
231
|
+
async setRetention(days, options = {}) {
|
|
232
|
+
return this.#lifecycle("retention", { ...options, wait: false }, { days });
|
|
233
|
+
}
|
|
234
|
+
/** Keeps this sandbox's whole machine (files, memory, running processes) as
|
|
235
|
+
* a snapshot to start new sandboxes from. A running sandbox is paused for
|
|
236
|
+
* the moment it takes, then woken; a paused one stays paused. Past
|
|
237
|
+
* `timeoutMs` (ten minutes unless given) mid-capture, it stays paused and
|
|
238
|
+
* the error names the snapshot. */
|
|
239
|
+
async snapshot(options = {}) {
|
|
240
|
+
const { idempotencyKey, signal, timeoutMs, ...body } = options;
|
|
241
|
+
if (body.mode !== undefined && body.mode !== "memory" && body.mode !== "disk")
|
|
242
|
+
throw new TypeError("Snapshot mode must be memory or disk.");
|
|
243
|
+
signal?.throwIfAborted();
|
|
244
|
+
// One minute, whatever `timeoutMs` said, was too short: pausing a busy
|
|
245
|
+
// 16 vCPU, 32 GiB sandbox writes its memory out first (4 October 2026).
|
|
246
|
+
const until = performance.now() + (timeoutMs && timeoutMs > 0 ? timeoutMs : SNAPSHOT_DEADLINE_MS);
|
|
247
|
+
let running = false;
|
|
248
|
+
/** The capture in progress, once the server has one. */
|
|
249
|
+
let capturing;
|
|
250
|
+
let late = false;
|
|
251
|
+
const request = () => {
|
|
252
|
+
signal?.throwIfAborted();
|
|
253
|
+
const left = Math.ceil(until - performance.now());
|
|
254
|
+
if (left <= 0 && capturing) {
|
|
255
|
+
late = true;
|
|
256
|
+
throw new RuntimeError({
|
|
257
|
+
code: "snapshot_timeout",
|
|
258
|
+
status: 0,
|
|
259
|
+
message: `Snapshot ${capturing} was still capturing at the deadline.${running ? ` The ${this.#product.noun.toLowerCase()} stays paused until it ends: waking it now would fail the capture.` : ""}`,
|
|
260
|
+
hint: `Wait until runtime.snapshots.get("${capturing}") is ready${running ? `, then wake the ${this.#product.noun.toLowerCase()}` : ""}, or pass a longer timeoutMs.`,
|
|
261
|
+
details: { snapshotId: capturing, sourceSandboxId: this.id },
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
if (left <= 0)
|
|
265
|
+
throw new RuntimeError({
|
|
266
|
+
code: "snapshot_timeout",
|
|
267
|
+
status: 0,
|
|
268
|
+
message: "Snapshot capture ran past its deadline.",
|
|
269
|
+
});
|
|
270
|
+
return { ...(signal ? { signal } : {}), timeoutMs: left };
|
|
271
|
+
};
|
|
272
|
+
await this.refresh(request());
|
|
273
|
+
// Straight after a fork or a wake the sandbox is still `resuming`, and
|
|
274
|
+
// after a pause still `pausing`: wait for where it is going, or a
|
|
275
|
+
// snapshot of it is refused as not paused (user lane, 23 September 2026).
|
|
276
|
+
if (this.state === "resuming" || this.state === "starting")
|
|
277
|
+
await this.waitFor("running", request());
|
|
278
|
+
else if (this.state === "pausing")
|
|
279
|
+
await this.waitFor("paused", request());
|
|
280
|
+
running = this.state === "running";
|
|
281
|
+
// Check before entering cleanup: an abort during refresh must not pause or wake.
|
|
282
|
+
const pauseOptions = running ? request() : undefined;
|
|
283
|
+
let captured;
|
|
284
|
+
let primary;
|
|
285
|
+
let failed = false;
|
|
286
|
+
try {
|
|
287
|
+
if (running)
|
|
288
|
+
await this.pause(pauseOptions);
|
|
289
|
+
let snapshot = await this.#t.json({
|
|
290
|
+
method: "POST",
|
|
291
|
+
path: this.#path(":snapshot"),
|
|
292
|
+
body,
|
|
293
|
+
wait: 10,
|
|
294
|
+
...(idempotencyKey ? { idempotencyKey } : {}),
|
|
295
|
+
...request(),
|
|
296
|
+
});
|
|
297
|
+
captured = snapshot;
|
|
298
|
+
// Prefer: wait is bounded on the server. A capture still in progress
|
|
299
|
+
// must keep its source paused, or the worker refuses it after we wake.
|
|
300
|
+
while (snapshot.state === "capturing") {
|
|
301
|
+
capturing = snapshot.id;
|
|
302
|
+
request();
|
|
303
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
304
|
+
snapshot = await this.#t.json({
|
|
305
|
+
method: "GET",
|
|
306
|
+
path: `/v1/snapshots/${enc(snapshot.id)}`,
|
|
307
|
+
...request(),
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
if (snapshot.state !== "ready")
|
|
311
|
+
throw new RuntimeError({
|
|
312
|
+
code: "snapshot_failed",
|
|
313
|
+
status: 409,
|
|
314
|
+
message: snapshot.error ?? `Snapshot capture ended in state ${snapshot.state}.`,
|
|
315
|
+
details: { snapshotId: snapshot.id },
|
|
316
|
+
});
|
|
317
|
+
if (body.mode === "disk" && snapshot.mode !== "disk")
|
|
318
|
+
throw new RuntimeError({
|
|
319
|
+
code: "snapshot_mode_mismatch",
|
|
320
|
+
status: 409,
|
|
321
|
+
message: "The server did not confirm a disk-only snapshot.",
|
|
322
|
+
details: { snapshotId: snapshot.id },
|
|
323
|
+
});
|
|
324
|
+
captured = snapshot;
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
failed = true;
|
|
328
|
+
primary = error;
|
|
329
|
+
// A request cut off by the deadline mid-capture is late all the same.
|
|
330
|
+
if (capturing && performance.now() >= until)
|
|
331
|
+
late = true;
|
|
332
|
+
}
|
|
333
|
+
// A capture still running needs its source paused: the worker fails one
|
|
334
|
+
// whose source woke. The timeout says so and leaves the wake to the caller.
|
|
335
|
+
if (running && !late) {
|
|
336
|
+
try {
|
|
337
|
+
await this.wake();
|
|
338
|
+
}
|
|
339
|
+
catch (wakeError) {
|
|
340
|
+
const recovery = {
|
|
341
|
+
...(captured ? { snapshotId: captured.id } : {}),
|
|
342
|
+
sourceSandboxId: this.id,
|
|
343
|
+
sourceWakeError: {
|
|
344
|
+
...(wakeError instanceof RuntimeError ? { code: wakeError.code } : {}),
|
|
345
|
+
message: wakeError instanceof Error ? wakeError.message : String(wakeError),
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
const reported = failed ? primary : wakeError;
|
|
349
|
+
if (reported instanceof Error && Object.isExtensible(reported)) {
|
|
350
|
+
if (reported instanceof RuntimeError)
|
|
351
|
+
Object.assign(reported, { details: { ...reported.details, ...recovery } });
|
|
352
|
+
else
|
|
353
|
+
Object.assign(reported, recovery);
|
|
354
|
+
}
|
|
355
|
+
if (!failed)
|
|
356
|
+
throw wakeError;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (failed)
|
|
360
|
+
throw primary;
|
|
361
|
+
return captured;
|
|
362
|
+
}
|
|
363
|
+
/** Starts a stopped persistent sandbox again from its disk (memory is not kept). */
|
|
364
|
+
async restart(options = {}) {
|
|
365
|
+
return this.#lifecycle("restart", options);
|
|
366
|
+
}
|
|
367
|
+
async #lifecycle(verb, options, body = {}) {
|
|
368
|
+
this.#keep(await this.#t.json({
|
|
369
|
+
method: "POST",
|
|
370
|
+
path: this.#path(`:${verb}`),
|
|
371
|
+
body,
|
|
372
|
+
wait: options.wait === false ? 0 : 60,
|
|
373
|
+
...requestOnly(options),
|
|
374
|
+
}));
|
|
375
|
+
return this;
|
|
376
|
+
}
|
|
377
|
+
async [Symbol.asyncDispose]() {
|
|
378
|
+
this.#keepAlive?.();
|
|
379
|
+
if (this.#info.state === "stopped")
|
|
380
|
+
return;
|
|
381
|
+
await this.stop({ wait: false }).catch(() => undefined);
|
|
382
|
+
}
|
|
383
|
+
toJSON() {
|
|
384
|
+
return this.#info;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/** A machine product's collection: `runtime.<product>`. */
|
|
388
|
+
export class Machines {
|
|
389
|
+
#t;
|
|
390
|
+
#product;
|
|
391
|
+
#wrap;
|
|
392
|
+
constructor(product, t, wrap) {
|
|
393
|
+
this.#product = product;
|
|
394
|
+
this.#t = t;
|
|
395
|
+
this.#wrap = wrap;
|
|
396
|
+
}
|
|
397
|
+
/** Creates a sandbox and waits until it is running. Every field is optional:
|
|
398
|
+
* with none you get the included usage while it lasts, the default region and a
|
|
399
|
+
* 2 vCPU / 4 GiB machine from a warm template. `wait: false` returns at once.
|
|
400
|
+
* When every slot without credit or the account's quota is taken, it waits for one to
|
|
401
|
+
* free, up to `waitForCapacityMs` (the client's, two minutes by default). */
|
|
402
|
+
async create(input = {}, options = {}) {
|
|
403
|
+
const { wait, ...body } = input;
|
|
404
|
+
const began = performance.now();
|
|
405
|
+
// Time spent waiting for capacity is the room's, not the deadline's.
|
|
406
|
+
let waited = 0;
|
|
407
|
+
const info = await this.#t.json({
|
|
408
|
+
method: "POST",
|
|
409
|
+
path: `/v1/${this.#product.plural}`,
|
|
410
|
+
body,
|
|
411
|
+
wait: wait === false ? 0 : 60,
|
|
412
|
+
...options,
|
|
413
|
+
waitForCapacityMs: options.waitForCapacityMs ?? this.#t.waitForCapacityMs,
|
|
414
|
+
onCapacityWait: (refusal, waitMs) => {
|
|
415
|
+
waited += waitMs;
|
|
416
|
+
options.onCapacityWait?.(refusal, waitMs);
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
const machine = this.#wrap(this.#t, info);
|
|
420
|
+
if (wait !== false && info.state !== "running") {
|
|
421
|
+
// One deadline for the whole create: the wait for running gets what the
|
|
422
|
+
// create left of it (1 ms when nothing is left, so it ends as a timeout).
|
|
423
|
+
const { timeoutMs } = options;
|
|
424
|
+
await machine.waitFor("running", {
|
|
425
|
+
timeoutSeconds: WAIT_FOR_TIMEOUT_SECONDS,
|
|
426
|
+
signal: options.signal,
|
|
427
|
+
timeoutMs: timeoutMs
|
|
428
|
+
? Math.max(1, Math.ceil(timeoutMs - (performance.now() - began - waited)))
|
|
429
|
+
: timeoutMs,
|
|
430
|
+
idempotencyKey: options.idempotencyKey,
|
|
431
|
+
});
|
|
432
|
+
if (machine.state !== "running")
|
|
433
|
+
throw new RuntimeError({
|
|
434
|
+
message: `${this.#product.noun} ${info.id} is ${machine.state}, not running.`,
|
|
435
|
+
code: "start_failed",
|
|
436
|
+
status: 0,
|
|
437
|
+
hint: `Read it with runtime.${this.#product.plural}.get(id); stopReason says why.`,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
return machine;
|
|
441
|
+
}
|
|
442
|
+
/** The sandbox named `name` in this account, ready to use: running as it
|
|
443
|
+
* is, woken if it is paused, restarted if it is stopped and persistent, or
|
|
444
|
+
* created with `input` when no sandbox has the name. `sandbox.info.reused`
|
|
445
|
+
* says which. The other fields apply only when it is created. */
|
|
446
|
+
async getOrCreate(name, input = {}, options = {}) {
|
|
447
|
+
return this.create({ ...input, name, getOrCreate: true }, options);
|
|
448
|
+
}
|
|
449
|
+
async get(id, options = {}) {
|
|
450
|
+
return this.#wrap(this.#t, await this.#t.json({
|
|
451
|
+
method: "GET",
|
|
452
|
+
path: `/v1/${this.#product.plural}/${encodeURIComponent(id)}`,
|
|
453
|
+
...options,
|
|
454
|
+
}));
|
|
455
|
+
}
|
|
456
|
+
/** Deletes a sandbox for good, by id, without reading it first: see
|
|
457
|
+
* `sandbox.delete()`. Deleting it again answers the same. */
|
|
458
|
+
delete(id, options = {}) {
|
|
459
|
+
return deleteMachine(this.#product, this.#t, id, options);
|
|
460
|
+
}
|
|
461
|
+
/** Live sandboxes, oldest first. Await for a page, or `for await` over all.
|
|
462
|
+
* Request options apply to the initial request and every subsequent page. */
|
|
463
|
+
async list(filter = {}, options = {}) {
|
|
464
|
+
const query = {
|
|
465
|
+
...(filter.state ? { state: filter.state } : {}),
|
|
466
|
+
...(filter.includeStopped ? { includeStopped: true } : {}),
|
|
467
|
+
...(filter.labels
|
|
468
|
+
? { label: Object.entries(filter.labels).map(([k, v]) => `${k}:${v}`) }
|
|
469
|
+
: {}),
|
|
470
|
+
...(filter.name ? { name: filter.name } : {}),
|
|
471
|
+
...(filter.limit ? { limit: filter.limit } : {}),
|
|
472
|
+
};
|
|
473
|
+
return machinePage(this.#product, this.#t, this.#wrap, await this.#t.json({ method: "GET", path: `/v1/${this.#product.plural}`, query, ...options }), query, options);
|
|
474
|
+
}
|
|
475
|
+
/** Stops every live sandbox whose labels all match, eight at a time, and
|
|
476
|
+
* says which stopped and which failed; one failure does not stop the rest.
|
|
477
|
+
* Needs at least one label, so stopping everything is never one call. */
|
|
478
|
+
async stopAll(filter, options = {}) {
|
|
479
|
+
if (!filter?.labels || Object.keys(filter.labels).length === 0) {
|
|
480
|
+
const one = this.#product.noun.toLowerCase();
|
|
481
|
+
throw new RuntimeError({
|
|
482
|
+
message: "stopAll needs at least one label to match.",
|
|
483
|
+
code: "invalid_request",
|
|
484
|
+
status: 0,
|
|
485
|
+
hint: `Stop one ${one} with ${one}.stop(), or label the ones to stop together.`,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
const live = await (await this.list({ labels: filter.labels, limit: 100 }, options)).toArray(Infinity);
|
|
489
|
+
// One key cannot name several stops: each stop makes its own.
|
|
490
|
+
const { idempotencyKey: _one, ...each } = options;
|
|
491
|
+
// In list order, oldest first, whichever finishes first.
|
|
492
|
+
const errors = new Array(live.length);
|
|
493
|
+
let next = 0;
|
|
494
|
+
const worker = async () => {
|
|
495
|
+
for (let at = next++; at < live.length; at = next++)
|
|
496
|
+
await live[at].stop(each).catch((error) => (errors[at] = error ?? "failed"));
|
|
497
|
+
};
|
|
498
|
+
await Promise.all(Array.from({ length: Math.min(8, live.length) }, worker));
|
|
499
|
+
return {
|
|
500
|
+
stopped: live.filter((_, at) => errors[at] === undefined).map((m) => m.id),
|
|
501
|
+
failed: live.flatMap((m, at) => errors[at] === undefined ? [] : [{ id: m.id, error: errors[at] }]),
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
}
|
package/dist/modal/index.js
CHANGED
|
@@ -164,8 +164,12 @@ export class SandboxService {
|
|
|
164
164
|
vcpu: params.cpu,
|
|
165
165
|
memoryMiB: params.memoryMiB,
|
|
166
166
|
region: placement,
|
|
167
|
-
|
|
168
|
-
|
|
167
|
+
// A timeout is the customer's limit, and the sandbox never pauses
|
|
168
|
+
// for idleness before it. None is no time limit (0300): it runs while
|
|
169
|
+
// it works and pauses when idle.
|
|
170
|
+
...(params.timeoutMs === undefined
|
|
171
|
+
? {}
|
|
172
|
+
: { timeoutSeconds: Math.ceil(params.timeoutMs / 1000), idlePauseSeconds: 0 }),
|
|
169
173
|
onLeaseEnd: "stop",
|
|
170
174
|
network: {
|
|
171
175
|
internet: !params.blockNetwork,
|
package/dist/network-cli.js
CHANGED
|
@@ -25,7 +25,7 @@ export const NETWORK_HELP = {
|
|
|
25
25
|
ls Your domains
|
|
26
26
|
get <hostname> One domain and its DNS records
|
|
27
27
|
rm <hostname> Stop serving it
|
|
28
|
-
|
|
28
|
+
Needs a kept top-up. The first visit gets the certificate, in a few seconds.
|
|
29
29
|
`,
|
|
30
30
|
port: `runtime port <command>
|
|
31
31
|
|
|
@@ -34,7 +34,7 @@ export const NETWORK_HELP = {
|
|
|
34
34
|
Prints address:port for your client
|
|
35
35
|
ls [--sandbox <id>] Your open ports
|
|
36
36
|
close <portId> Close one, its open connections too
|
|
37
|
-
|
|
37
|
+
Needs a kept top-up. TCP, carried as it is: use your service's own password and TLS.
|
|
38
38
|
`,
|
|
39
39
|
address: `runtime address <command>
|
|
40
40
|
|
|
@@ -42,7 +42,7 @@ export const NETWORK_HELP = {
|
|
|
42
42
|
account sends from it, for allow-lists
|
|
43
43
|
ls Your addresses
|
|
44
44
|
release <addressId> Give it back; sandboxes use the shared addresses again
|
|
45
|
-
|
|
45
|
+
Needs credit on the account.
|
|
46
46
|
`,
|
|
47
47
|
tunnel: `runtime tunnel <command>
|
|
48
48
|
|
|
@@ -56,7 +56,7 @@ export const NETWORK_HELP = {
|
|
|
56
56
|
peer rotate <peerId> [--out runtime.conf]
|
|
57
57
|
A new key; the old one stops working within seconds
|
|
58
58
|
peer rm <peerId> Remove it; its tunnel ends within seconds
|
|
59
|
-
--route: your own ranges behind the peer that sandboxes may reach.
|
|
59
|
+
--route: your own ranges behind the peer that sandboxes may reach. Needs a kept top-up.
|
|
60
60
|
`,
|
|
61
61
|
network: `runtime network <command>
|
|
62
62
|
|
|
@@ -69,13 +69,13 @@ export const NETWORK_HELP = {
|
|
|
69
69
|
upstream-proxy get The proxy in use
|
|
70
70
|
upstream-proxy remove Connections leave directly again
|
|
71
71
|
A proxy that refuses or cannot be reached fails the connection; nothing goes
|
|
72
|
-
around it.
|
|
72
|
+
around it. Needs a kept top-up.
|
|
73
73
|
|
|
74
74
|
private on Your sandboxes reach each other by name, over TCP:
|
|
75
75
|
<name>.sandbox.internal:<port> or <id>.sandbox.internal:<port>
|
|
76
76
|
private off They stop; open connections between them are cut
|
|
77
77
|
private status On or off
|
|
78
|
-
Only your own sandboxes answer.
|
|
78
|
+
Only your own sandboxes answer. Needs a kept top-up; free.
|
|
79
79
|
`,
|
|
80
80
|
};
|
|
81
81
|
function usage(product, message) {
|
|
@@ -411,7 +411,7 @@ export async function networkProductCommand(product, argv, args, out, client) {
|
|
|
411
411
|
? "On: your sandboxes reach each other at <name>.sandbox.internal:<port> over TCP."
|
|
412
412
|
: state.allowed
|
|
413
413
|
? "Off. Turn it on: runtime network private on"
|
|
414
|
-
: "Off. Reaching your sandboxes by name
|
|
414
|
+
: "Off. Reaching your sandboxes by name needs a kept top-up.";
|
|
415
415
|
switch (rest[0]) {
|
|
416
416
|
case "on":
|
|
417
417
|
case "off": {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* const agent = new SandboxAgent({ name: "Coder", instructions: "Fix the failing test." });
|
|
13
13
|
* await run(agent, "Go.", {
|
|
14
|
-
* sandbox: { client: new RuntimeCloudSandboxClient(
|
|
14
|
+
* sandbox: { client: new RuntimeCloudSandboxClient() },
|
|
15
15
|
* });
|
|
16
16
|
*
|
|
17
17
|
* Needs `@openai/agents` 0.18 or newer beside this package. The key comes from
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* const agent = new SandboxAgent({ name: "Coder", instructions: "Fix the failing test." });
|
|
13
13
|
* await run(agent, "Go.", {
|
|
14
|
-
* sandbox: { client: new RuntimeCloudSandboxClient(
|
|
14
|
+
* sandbox: { client: new RuntimeCloudSandboxClient() },
|
|
15
15
|
* });
|
|
16
16
|
*
|
|
17
17
|
* Needs `@openai/agents` 0.18 or newer beside this package. The key comes from
|
|
@@ -34,7 +34,8 @@ export type BrowserStartOptions = {
|
|
|
34
34
|
export declare function sandboxBrowser(t: Transport, sandbox: Sandbox): {
|
|
35
35
|
/** Starts Chromium and returns its CDP address. The first start in a
|
|
36
36
|
* sandbox installs Chromium, about a minute; this waits for it (up to 10
|
|
37
|
-
* minutes). Starting a running browser returns it
|
|
37
|
+
* minutes, or the call's timeoutMs). Starting a running browser returns it
|
|
38
|
+
* as it is. */
|
|
38
39
|
start(input?: BrowserStartOptions, options?: RequestOptions): Promise<{
|
|
39
40
|
running: true;
|
|
40
41
|
headless: boolean;
|
package/dist/products/browser.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { whileInstalling } from "../wait.js";
|
|
1
2
|
/** `sbx.browser`: Chromium in the sandbox, for agents that speak CDP.
|
|
2
3
|
*
|
|
3
4
|
* const { cdpUrl } = await sbx.browser.start();
|
|
@@ -9,26 +10,15 @@ export function sandboxBrowser(t, sandbox) {
|
|
|
9
10
|
return {
|
|
10
11
|
/** Starts Chromium and returns its CDP address. The first start in a
|
|
11
12
|
* sandbox installs Chromium, about a minute; this waits for it (up to 10
|
|
12
|
-
* minutes). Starting a running browser returns it
|
|
13
|
+
* minutes, or the call's timeoutMs). Starting a running browser returns it
|
|
14
|
+
* as it is. */
|
|
13
15
|
async start(input = {}, options) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
body: input,
|
|
21
|
-
...options,
|
|
22
|
-
});
|
|
23
|
-
}
|
|
24
|
-
catch (error) {
|
|
25
|
-
const code = error.code;
|
|
26
|
-
if ((code !== "browser_installing" && code !== "desktop_installing") ||
|
|
27
|
-
Date.now() > deadline)
|
|
28
|
-
throw error;
|
|
29
|
-
await new Promise((resolve) => setTimeout(resolve, error.retryAfterMs ?? 10_000));
|
|
30
|
-
}
|
|
31
|
-
}
|
|
16
|
+
return whileInstalling(["browser_installing", "desktop_installing"], options, (request) => t.json({
|
|
17
|
+
method: "POST",
|
|
18
|
+
path: `${base()}:start`,
|
|
19
|
+
body: input,
|
|
20
|
+
...request,
|
|
21
|
+
}));
|
|
32
22
|
},
|
|
33
23
|
/** Whether it runs, and a fresh cdpUrl when it does. */
|
|
34
24
|
get: (options) => t.json({ method: "GET", path: base(), ...options }),
|
|
@@ -53,7 +53,8 @@ export type RecordOptions = {
|
|
|
53
53
|
* Coordinates are pixels from the top left of the screen. */
|
|
54
54
|
export declare function sandboxDesktop(t: Transport, sandbox: Sandbox): {
|
|
55
55
|
/** Starts the desktop. The first start in a sandbox installs it, which
|
|
56
|
-
* takes a minute or two; this waits for that (up to 10 minutes
|
|
56
|
+
* takes a minute or two; this waits for that (up to 10 minutes, or the
|
|
57
|
+
* call's timeoutMs). */
|
|
57
58
|
start(size?: {
|
|
58
59
|
width?: number;
|
|
59
60
|
height?: number;
|