withruntime 0.2.1 → 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 +68 -4
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +1262 -0
- package/dist/client.d.ts +118 -0
- package/dist/client.js +160 -0
- package/dist/credentials.d.ts +44 -0
- package/dist/credentials.js +180 -0
- package/dist/errors.d.ts +58 -0
- package/dist/errors.js +106 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +25 -0
- package/dist/login.d.ts +119 -0
- package/dist/login.js +367 -0
- package/dist/mcp.d.ts +15 -0
- package/dist/mcp.js +221 -0
- package/dist/page.d.ts +13 -0
- package/dist/page.js +39 -0
- package/dist/products/desktop.d.ts +103 -0
- package/dist/products/desktop.js +38 -0
- package/dist/products/images.d.ts +121 -0
- package/dist/products/images.js +64 -0
- package/dist/products/index.d.ts +29 -0
- package/dist/products/index.js +36 -0
- package/dist/products/interpreter.d.ts +88 -0
- package/dist/products/interpreter.js +81 -0
- package/dist/products/network.d.ts +47 -0
- package/dist/products/network.js +19 -0
- package/dist/products/previews.d.ts +51 -0
- package/dist/products/previews.js +30 -0
- package/dist/products/referrals.d.ts +36 -0
- package/dist/products/referrals.js +8 -0
- package/dist/products/volumes.d.ts +47 -0
- package/dist/products/volumes.js +27 -0
- package/dist/sandbox.d.ts +192 -0
- package/dist/sandbox.js +628 -0
- package/dist/snapshots.d.ts +50 -0
- package/dist/snapshots.js +55 -0
- package/dist/tar.d.ts +3 -0
- package/dist/tar.js +116 -0
- package/dist/transport.d.ts +65 -0
- package/dist/transport.js +261 -0
- package/dist/types.d.ts +175 -0
- package/dist/types.js +1 -0
- package/package.json +53 -8
- package/cli.js +0 -8
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
import { CommandError, RuntimeError } from "./errors.js";
|
|
2
|
+
import { Page } from "./page.js";
|
|
3
|
+
import { sandboxFactories } from "./products/index.js";
|
|
4
|
+
const CHUNK = 1_048_576;
|
|
5
|
+
const PARALLEL = 4;
|
|
6
|
+
const enc = (id) => encodeURIComponent(id);
|
|
7
|
+
const toBase64 = (data) => (typeof data === "string" ? Buffer.from(data) : Buffer.from(data)).toString("base64");
|
|
8
|
+
function commandBody(command, options) {
|
|
9
|
+
return {
|
|
10
|
+
...(typeof command === "string" ? { command } : { argv: [...command] }),
|
|
11
|
+
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
|
12
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
13
|
+
...(options.stdin === undefined
|
|
14
|
+
? {}
|
|
15
|
+
: typeof options.stdin === "string"
|
|
16
|
+
? { stdin: options.stdin }
|
|
17
|
+
: { stdinBase64: toBase64(options.stdin) }),
|
|
18
|
+
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
|
22
|
+
export class Sandbox {
|
|
23
|
+
#info;
|
|
24
|
+
#t;
|
|
25
|
+
files;
|
|
26
|
+
processes;
|
|
27
|
+
constructor(transport, info) {
|
|
28
|
+
this.#t = transport;
|
|
29
|
+
this.#info = info;
|
|
30
|
+
this.files = new Files(transport, info.id);
|
|
31
|
+
this.processes = new Processes(transport, info.id);
|
|
32
|
+
for (const [name, make] of sandboxFactories())
|
|
33
|
+
Object.defineProperty(this, name, { value: make(transport, this), enumerable: false });
|
|
34
|
+
}
|
|
35
|
+
get id() {
|
|
36
|
+
return this.#info.id;
|
|
37
|
+
}
|
|
38
|
+
/** What the API last said about this sandbox. `refresh()` asks again. */
|
|
39
|
+
get info() {
|
|
40
|
+
return this.#info;
|
|
41
|
+
}
|
|
42
|
+
get state() {
|
|
43
|
+
return this.#info.state;
|
|
44
|
+
}
|
|
45
|
+
async refresh(options = {}) {
|
|
46
|
+
this.#info = await this.#t.json({
|
|
47
|
+
method: "GET",
|
|
48
|
+
path: `/v1/sandboxes/${enc(this.id)}`,
|
|
49
|
+
...options,
|
|
50
|
+
});
|
|
51
|
+
return this;
|
|
52
|
+
}
|
|
53
|
+
/** Waits (server-side, no polling) until the sandbox reaches `state`. */
|
|
54
|
+
async waitFor(state, options = {}) {
|
|
55
|
+
this.#info = await this.#t.json({
|
|
56
|
+
method: "GET",
|
|
57
|
+
path: `/v1/sandboxes/${enc(this.id)}`,
|
|
58
|
+
query: { waitFor: state, timeoutSeconds: options.timeoutSeconds ?? 60 },
|
|
59
|
+
...options,
|
|
60
|
+
});
|
|
61
|
+
return this;
|
|
62
|
+
}
|
|
63
|
+
/** Runs a command and returns its exit code and output. A string runs under
|
|
64
|
+
* `bash -c`; an array runs without a shell. With onStdout/onStderr the output
|
|
65
|
+
* streams as it happens. A timeout is a result (timedOut: true, the output so
|
|
66
|
+
* far), never an error; `check: true` throws CommandError on a non-zero exit. */
|
|
67
|
+
async exec(command, options = {}) {
|
|
68
|
+
const streaming = options.onStdout || options.onStderr || (options.timeoutMs ?? 0) > 60_000;
|
|
69
|
+
let result;
|
|
70
|
+
if (!streaming) {
|
|
71
|
+
result = await this.#t.json({
|
|
72
|
+
method: "POST",
|
|
73
|
+
path: `/v1/sandboxes/${enc(this.id)}:exec`,
|
|
74
|
+
body: commandBody(command, options),
|
|
75
|
+
...pick(options),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
const out = { stdout: "", stderr: "" };
|
|
80
|
+
let exit;
|
|
81
|
+
let processId;
|
|
82
|
+
for await (const event of this.execStream(command, options)) {
|
|
83
|
+
if (event.type === "start")
|
|
84
|
+
processId = event.processId;
|
|
85
|
+
else if (event.type === "stdout" || event.type === "stderr") {
|
|
86
|
+
out[event.type] += event.data;
|
|
87
|
+
(event.type === "stdout" ? options.onStdout : options.onStderr)?.(event.data);
|
|
88
|
+
}
|
|
89
|
+
else if (event.type === "exit")
|
|
90
|
+
exit = event;
|
|
91
|
+
}
|
|
92
|
+
result = {
|
|
93
|
+
exitCode: exit?.exitCode ?? null,
|
|
94
|
+
...out,
|
|
95
|
+
timedOut: exit?.timedOut ?? false,
|
|
96
|
+
...(exit?.durationMs === undefined ? {} : { durationMs: exit.durationMs }),
|
|
97
|
+
...(processId ? { processId } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (options.check && (result.exitCode !== 0 || result.timedOut))
|
|
101
|
+
throw new CommandError(result);
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
/** The command's events as they happen: start, stdout, stderr, exit. Resumes
|
|
105
|
+
* by itself when the server ends a long stream, so it never drops output. */
|
|
106
|
+
async *execStream(command, options = {}) {
|
|
107
|
+
let processId;
|
|
108
|
+
let cursor = 0;
|
|
109
|
+
const events = this.#t.events({
|
|
110
|
+
method: "POST",
|
|
111
|
+
path: `/v1/sandboxes/${enc(this.id)}:exec`,
|
|
112
|
+
body: { ...commandBody(command, { timeoutMs: 86_400_000, ...options }), stream: true },
|
|
113
|
+
...pick(options),
|
|
114
|
+
timeoutMs: options.timeoutMs === undefined ? 86_400_000 : options.timeoutMs + 60_000,
|
|
115
|
+
});
|
|
116
|
+
for await (const event of events) {
|
|
117
|
+
if (event.type === "start")
|
|
118
|
+
processId = event.processId;
|
|
119
|
+
if (event.type === "stdout" || event.type === "stderr")
|
|
120
|
+
cursor = event.offset + Buffer.byteLength(event.data);
|
|
121
|
+
if (event.type === "continue") {
|
|
122
|
+
cursor = event.cursor;
|
|
123
|
+
yield* this.processes.follow(event.processId, { cursor, signal: options.signal });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (event.type === "error")
|
|
127
|
+
throw new RuntimeError({
|
|
128
|
+
message: event.error.message,
|
|
129
|
+
code: event.error.code,
|
|
130
|
+
status: 0,
|
|
131
|
+
...(event.error.requestId ? { requestId: event.error.requestId } : {}),
|
|
132
|
+
});
|
|
133
|
+
yield event;
|
|
134
|
+
}
|
|
135
|
+
void processId;
|
|
136
|
+
}
|
|
137
|
+
/** Starts a background process (a server, a watcher, a REPL) and returns at
|
|
138
|
+
* once. `stdin: "pipe"` keeps its input open for write(); `pty` gives it a
|
|
139
|
+
* terminal. */
|
|
140
|
+
async spawn(command, options = {}) {
|
|
141
|
+
const { stdin, pty, ...rest } = options;
|
|
142
|
+
const info = await this.#t.json({
|
|
143
|
+
method: "POST",
|
|
144
|
+
path: `/v1/sandboxes/${enc(this.id)}/processes`,
|
|
145
|
+
body: {
|
|
146
|
+
...commandBody(command, rest),
|
|
147
|
+
...(stdin === "pipe" ? { stdinMode: "pipe" } : stdin === undefined ? {} : { stdin }),
|
|
148
|
+
...(pty ? { pty } : {}),
|
|
149
|
+
},
|
|
150
|
+
...pick(rest),
|
|
151
|
+
});
|
|
152
|
+
return new Process(this.#t, this.id, info);
|
|
153
|
+
}
|
|
154
|
+
/** An interactive terminal over a WebSocket. Bytes you write are typed;
|
|
155
|
+
* onData receives what the terminal prints. */
|
|
156
|
+
async terminal(options = {}) {
|
|
157
|
+
const socket = await this.#t.websocket(`/v1/sandboxes/${enc(this.id)}/terminal`, {
|
|
158
|
+
cols: options.cols,
|
|
159
|
+
rows: options.rows,
|
|
160
|
+
command: options.command,
|
|
161
|
+
cwd: options.cwd,
|
|
162
|
+
processId: options.processId,
|
|
163
|
+
});
|
|
164
|
+
return Terminal.open(socket, options.onData);
|
|
165
|
+
}
|
|
166
|
+
async stop(options = {}) {
|
|
167
|
+
return this.#lifecycle("stop", options);
|
|
168
|
+
}
|
|
169
|
+
async pause(options = {}) {
|
|
170
|
+
return this.#lifecycle("pause", options);
|
|
171
|
+
}
|
|
172
|
+
/** Carries on a paused sandbox; `timeoutSeconds` is its new lease. */
|
|
173
|
+
async wake(options = {}) {
|
|
174
|
+
const { timeoutSeconds, ...rest } = options;
|
|
175
|
+
return this.#lifecycle("wake", rest, timeoutSeconds === undefined ? {} : { timeoutSeconds });
|
|
176
|
+
}
|
|
177
|
+
/** More time before the lease ends (at most an hour ahead of now). */
|
|
178
|
+
async extend(seconds, options = {}) {
|
|
179
|
+
return this.#lifecycle("extend", { ...options, wait: false }, { seconds });
|
|
180
|
+
}
|
|
181
|
+
/** Days a paused sandbox is kept before deletion (1 to 365). */
|
|
182
|
+
async setRetention(days, options = {}) {
|
|
183
|
+
return this.#lifecycle("retention", { ...options, wait: false }, { days });
|
|
184
|
+
}
|
|
185
|
+
/** Keeps this sandbox's whole machine (files, memory, running processes) as
|
|
186
|
+
* a snapshot to start new sandboxes from. A running sandbox is paused for
|
|
187
|
+
* the moment it takes, then woken; a paused one stays paused. */
|
|
188
|
+
async snapshot(options = {}) {
|
|
189
|
+
const { idempotencyKey, signal, timeoutMs, ...body } = options;
|
|
190
|
+
await this.refresh();
|
|
191
|
+
const running = this.state === "running";
|
|
192
|
+
if (running)
|
|
193
|
+
await this.pause();
|
|
194
|
+
try {
|
|
195
|
+
return await this.#t.json({
|
|
196
|
+
method: "POST",
|
|
197
|
+
path: `/v1/sandboxes/${enc(this.id)}:snapshot`,
|
|
198
|
+
body,
|
|
199
|
+
wait: 10,
|
|
200
|
+
...pick({
|
|
201
|
+
...(idempotencyKey ? { idempotencyKey } : {}),
|
|
202
|
+
...(signal ? { signal } : {}),
|
|
203
|
+
...(timeoutMs ? { timeoutMs } : {}),
|
|
204
|
+
}),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
if (running)
|
|
209
|
+
await this.wake();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async fork(options = {}) {
|
|
213
|
+
const { idempotencyKey, signal, timeoutMs, ...body } = options;
|
|
214
|
+
const reply = await this.#t.json({
|
|
215
|
+
method: "POST",
|
|
216
|
+
path: `/v1/sandboxes/${enc(this.id)}:fork`,
|
|
217
|
+
body,
|
|
218
|
+
wait: 60,
|
|
219
|
+
...pick({
|
|
220
|
+
...(idempotencyKey ? { idempotencyKey } : {}),
|
|
221
|
+
...(signal ? { signal } : {}),
|
|
222
|
+
...(timeoutMs ? { timeoutMs } : {}),
|
|
223
|
+
}),
|
|
224
|
+
});
|
|
225
|
+
const sandboxes = reply.sandboxes.map((info) => new Sandbox(this.#t, info));
|
|
226
|
+
return options.count === undefined ? sandboxes[0] : sandboxes;
|
|
227
|
+
}
|
|
228
|
+
/** Starts a stopped persistent sandbox again from its disk (memory is not kept). */
|
|
229
|
+
async restart(options = {}) {
|
|
230
|
+
return this.#lifecycle("restart", options);
|
|
231
|
+
}
|
|
232
|
+
async #lifecycle(verb, options, body = {}) {
|
|
233
|
+
this.#info = await this.#t.json({
|
|
234
|
+
method: "POST",
|
|
235
|
+
path: `/v1/sandboxes/${enc(this.id)}:${verb}`,
|
|
236
|
+
body,
|
|
237
|
+
wait: options.wait === false ? 0 : 60,
|
|
238
|
+
...pick(options),
|
|
239
|
+
});
|
|
240
|
+
return this;
|
|
241
|
+
}
|
|
242
|
+
async [Symbol.asyncDispose]() {
|
|
243
|
+
if (this.#info.state === "stopped")
|
|
244
|
+
return;
|
|
245
|
+
await this.stop({ wait: false }).catch(() => undefined);
|
|
246
|
+
}
|
|
247
|
+
toJSON() {
|
|
248
|
+
return this.#info;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function pick(options) {
|
|
252
|
+
return {
|
|
253
|
+
...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
|
|
254
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
255
|
+
...(options.timeoutMs ? { timeoutMs: options.timeoutMs + 60_000 } : {}),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
export class Processes {
|
|
259
|
+
t;
|
|
260
|
+
sandboxId;
|
|
261
|
+
constructor(t, sandboxId) {
|
|
262
|
+
this.t = t;
|
|
263
|
+
this.sandboxId = sandboxId;
|
|
264
|
+
}
|
|
265
|
+
async list() {
|
|
266
|
+
return (await this.t.json({
|
|
267
|
+
method: "GET",
|
|
268
|
+
path: `/v1/sandboxes/${enc(this.sandboxId)}/processes`,
|
|
269
|
+
})).data;
|
|
270
|
+
}
|
|
271
|
+
async get(processId) {
|
|
272
|
+
const info = await this.t.json({
|
|
273
|
+
method: "GET",
|
|
274
|
+
path: `/v1/sandboxes/${enc(this.sandboxId)}/processes/${enc(processId)}`,
|
|
275
|
+
});
|
|
276
|
+
return new Process(this.t, this.sandboxId, info);
|
|
277
|
+
}
|
|
278
|
+
/** Output events from `cursor` until the process exits, across stream slices. */
|
|
279
|
+
async *follow(processId, options = {}) {
|
|
280
|
+
let cursor = options.cursor ?? 0;
|
|
281
|
+
for (;;) {
|
|
282
|
+
let resumed = false;
|
|
283
|
+
for await (const event of this.t.events({
|
|
284
|
+
method: "GET",
|
|
285
|
+
path: `/v1/sandboxes/${enc(this.sandboxId)}/processes/${enc(processId)}/output`,
|
|
286
|
+
query: { cursor, follow: true },
|
|
287
|
+
timeoutMs: 180_000,
|
|
288
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
289
|
+
})) {
|
|
290
|
+
if (event.type === "continue") {
|
|
291
|
+
cursor = event.cursor;
|
|
292
|
+
resumed = true;
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
if (event.type === "stdout" || event.type === "stderr")
|
|
296
|
+
cursor = event.offset + Buffer.byteLength(event.data);
|
|
297
|
+
if (event.type === "error")
|
|
298
|
+
throw new RuntimeError({
|
|
299
|
+
message: event.error.message,
|
|
300
|
+
code: event.error.code,
|
|
301
|
+
status: 0,
|
|
302
|
+
});
|
|
303
|
+
yield event;
|
|
304
|
+
if (event.type === "exit")
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (!resumed)
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/** A background process: its output, its input, its end. */
|
|
313
|
+
export class Process {
|
|
314
|
+
t;
|
|
315
|
+
sandboxId;
|
|
316
|
+
info;
|
|
317
|
+
#inputOffset;
|
|
318
|
+
constructor(t, sandboxId, info) {
|
|
319
|
+
this.t = t;
|
|
320
|
+
this.sandboxId = sandboxId;
|
|
321
|
+
this.info = info;
|
|
322
|
+
this.#inputOffset = info.stdinOffset ?? 0;
|
|
323
|
+
}
|
|
324
|
+
get id() {
|
|
325
|
+
return this.info.id;
|
|
326
|
+
}
|
|
327
|
+
/** Every output event from the start (or `cursor`) until exit. */
|
|
328
|
+
output(options = {}) {
|
|
329
|
+
return new Processes(this.t, this.sandboxId).follow(this.id, options);
|
|
330
|
+
}
|
|
331
|
+
/** Waits for the process to end and returns its result. */
|
|
332
|
+
async wait(options = {}) {
|
|
333
|
+
const out = { stdout: "", stderr: "" };
|
|
334
|
+
let exit;
|
|
335
|
+
for await (const event of this.output(options)) {
|
|
336
|
+
if (event.type === "stdout" || event.type === "stderr")
|
|
337
|
+
out[event.type] += event.data;
|
|
338
|
+
if (event.type === "exit")
|
|
339
|
+
exit = event;
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
exitCode: exit?.exitCode ?? null,
|
|
343
|
+
...out,
|
|
344
|
+
timedOut: exit?.timedOut ?? false,
|
|
345
|
+
processId: this.id,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
/** Sends input. Offsets are tracked for you, so a retried write is never typed twice. */
|
|
349
|
+
async write(data, options = {}) {
|
|
350
|
+
const bytes = typeof data === "string" ? Buffer.from(data) : Buffer.from(data);
|
|
351
|
+
let sent = 0;
|
|
352
|
+
do {
|
|
353
|
+
const reply = await this.t.json({
|
|
354
|
+
method: "POST",
|
|
355
|
+
path: `/v1/sandboxes/${enc(this.sandboxId)}/processes/${enc(this.id)}:write`,
|
|
356
|
+
body: {
|
|
357
|
+
base64: bytes.subarray(sent).toString("base64"),
|
|
358
|
+
offset: this.#inputOffset,
|
|
359
|
+
...(options.eof ? { eof: true } : {}),
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
sent += reply.offset - this.#inputOffset;
|
|
363
|
+
this.#inputOffset = reply.offset;
|
|
364
|
+
} while (sent < bytes.length);
|
|
365
|
+
}
|
|
366
|
+
async kill(signal = "SIGTERM") {
|
|
367
|
+
await this.t.json({
|
|
368
|
+
method: "POST",
|
|
369
|
+
path: `/v1/sandboxes/${enc(this.sandboxId)}/processes/${enc(this.id)}:signal`,
|
|
370
|
+
body: { signal },
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
async resize(cols, rows) {
|
|
374
|
+
await this.t.json({
|
|
375
|
+
method: "POST",
|
|
376
|
+
path: `/v1/sandboxes/${enc(this.sandboxId)}/processes/${enc(this.id)}:resize`,
|
|
377
|
+
body: { cols, rows },
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
async refresh() {
|
|
381
|
+
this.info = await this.t.json({
|
|
382
|
+
method: "GET",
|
|
383
|
+
path: `/v1/sandboxes/${enc(this.sandboxId)}/processes/${enc(this.id)}`,
|
|
384
|
+
});
|
|
385
|
+
return this.info;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
export class Terminal {
|
|
389
|
+
socket;
|
|
390
|
+
exited;
|
|
391
|
+
#processId;
|
|
392
|
+
constructor(socket, exited) {
|
|
393
|
+
this.socket = socket;
|
|
394
|
+
this.exited = exited;
|
|
395
|
+
}
|
|
396
|
+
get processId() {
|
|
397
|
+
return this.#processId;
|
|
398
|
+
}
|
|
399
|
+
static open(socket, onData) {
|
|
400
|
+
socket.binaryType = "arraybuffer";
|
|
401
|
+
let settle;
|
|
402
|
+
const exited = new Promise((resolve) => (settle = resolve));
|
|
403
|
+
let exitCode = null;
|
|
404
|
+
return new Promise((resolve, reject) => {
|
|
405
|
+
let terminal;
|
|
406
|
+
socket.onmessage = (event) => {
|
|
407
|
+
if (typeof event.data === "string") {
|
|
408
|
+
const message = JSON.parse(event.data);
|
|
409
|
+
if (message.type === "ready") {
|
|
410
|
+
terminal = new Terminal(socket, exited);
|
|
411
|
+
terminal.#processId = message.processId;
|
|
412
|
+
resolve(terminal);
|
|
413
|
+
}
|
|
414
|
+
else if (message.type === "exit")
|
|
415
|
+
exitCode = message.exitCode ?? null;
|
|
416
|
+
else if (message.type === "error" && !terminal)
|
|
417
|
+
reject(new RuntimeError({
|
|
418
|
+
message: message.error?.message ?? "Terminal failed.",
|
|
419
|
+
code: message.error?.code ?? "terminal_failed",
|
|
420
|
+
status: 0,
|
|
421
|
+
}));
|
|
422
|
+
}
|
|
423
|
+
else
|
|
424
|
+
onData?.(new Uint8Array(event.data));
|
|
425
|
+
};
|
|
426
|
+
socket.onclose = () => {
|
|
427
|
+
settle(exitCode);
|
|
428
|
+
if (!terminal)
|
|
429
|
+
reject(new RuntimeError({
|
|
430
|
+
message: "The terminal could not be opened (check the key and that the sandbox is running).",
|
|
431
|
+
code: "terminal_refused",
|
|
432
|
+
status: 0,
|
|
433
|
+
}));
|
|
434
|
+
};
|
|
435
|
+
socket.onerror = () => undefined;
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
write(data) {
|
|
439
|
+
this.socket.send(typeof data === "string" ? new TextEncoder().encode(data) : data);
|
|
440
|
+
}
|
|
441
|
+
resize(cols, rows) {
|
|
442
|
+
this.socket.send(JSON.stringify({ type: "resize", cols, rows }));
|
|
443
|
+
}
|
|
444
|
+
close() {
|
|
445
|
+
this.socket.close(1000);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/** Files in the sandbox. Paths are absolute; any path the sandbox user may use. */
|
|
449
|
+
export class Files {
|
|
450
|
+
t;
|
|
451
|
+
sandboxId;
|
|
452
|
+
constructor(t, sandboxId) {
|
|
453
|
+
this.t = t;
|
|
454
|
+
this.sandboxId = sandboxId;
|
|
455
|
+
}
|
|
456
|
+
#path = (suffix) => `/v1/sandboxes/${enc(this.sandboxId)}${suffix}`;
|
|
457
|
+
/** A file's bytes, any size. */
|
|
458
|
+
async read(path, options = {}) {
|
|
459
|
+
return this.t.bytes({
|
|
460
|
+
method: "GET",
|
|
461
|
+
path: this.#path("/files/content"),
|
|
462
|
+
query: { path },
|
|
463
|
+
accept: "application/octet-stream",
|
|
464
|
+
...options,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
async readText(path, options = {}) {
|
|
468
|
+
return new TextDecoder().decode(await this.read(path, options));
|
|
469
|
+
}
|
|
470
|
+
/** Writes a file of any size, atomically, making parent directories. Large
|
|
471
|
+
* ones go in parallel 1 MiB chunks checked against their SHA-256. */
|
|
472
|
+
async write(path, data, options = {}) {
|
|
473
|
+
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
474
|
+
if (bytes.length <= CHUNK) {
|
|
475
|
+
// bytes(), not send(): the answer is read, so its connection is free
|
|
476
|
+
// for the next call rather than held until garbage collection.
|
|
477
|
+
await this.t.bytes({
|
|
478
|
+
method: "PUT",
|
|
479
|
+
path: this.#path("/files/content"),
|
|
480
|
+
query: { path },
|
|
481
|
+
bytes,
|
|
482
|
+
...options,
|
|
483
|
+
});
|
|
484
|
+
return { path, size: bytes.length };
|
|
485
|
+
}
|
|
486
|
+
const { createHash } = await import("node:crypto");
|
|
487
|
+
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
488
|
+
const begin = await this.t.json({
|
|
489
|
+
method: "POST",
|
|
490
|
+
path: this.#path("/uploads"),
|
|
491
|
+
body: { path, size: bytes.length, sha256 },
|
|
492
|
+
...options,
|
|
493
|
+
});
|
|
494
|
+
const offsets = [];
|
|
495
|
+
for (let offset = 0; offset < bytes.length; offset += begin.chunkBytes)
|
|
496
|
+
offsets.push(offset);
|
|
497
|
+
let next = 0;
|
|
498
|
+
const worker = async () => {
|
|
499
|
+
while (next < offsets.length) {
|
|
500
|
+
const offset = offsets[next++];
|
|
501
|
+
await this.t.bytes({
|
|
502
|
+
method: "PUT",
|
|
503
|
+
path: this.#path(`/uploads/${enc(begin.uploadId)}`),
|
|
504
|
+
query: { offset },
|
|
505
|
+
bytes: bytes.subarray(offset, offset + begin.chunkBytes),
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
try {
|
|
510
|
+
await Promise.all(Array.from({ length: Math.min(PARALLEL, offsets.length) }, worker));
|
|
511
|
+
await this.t.json({
|
|
512
|
+
method: "POST",
|
|
513
|
+
path: this.#path(`/uploads/${enc(begin.uploadId)}:commit`),
|
|
514
|
+
body: {},
|
|
515
|
+
...options,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
catch (error) {
|
|
519
|
+
await this.t
|
|
520
|
+
.json({
|
|
521
|
+
method: "POST",
|
|
522
|
+
path: this.#path(`/uploads/${enc(begin.uploadId)}:abort`),
|
|
523
|
+
body: {},
|
|
524
|
+
})
|
|
525
|
+
.catch(() => undefined);
|
|
526
|
+
throw error;
|
|
527
|
+
}
|
|
528
|
+
return { path, size: bytes.length };
|
|
529
|
+
}
|
|
530
|
+
/** Directory entries; `depth` goes deeper, `glob` filters (e.g. "**\/*.py"). */
|
|
531
|
+
async list(path = "/workspace", options = {}) {
|
|
532
|
+
return (await this.t.json({
|
|
533
|
+
method: "GET",
|
|
534
|
+
path: this.#path("/files/list"),
|
|
535
|
+
query: { path, ...options },
|
|
536
|
+
})).data;
|
|
537
|
+
}
|
|
538
|
+
async glob(pattern, root = "/workspace") {
|
|
539
|
+
return this.list(root, { glob: pattern });
|
|
540
|
+
}
|
|
541
|
+
async stat(path) {
|
|
542
|
+
return this.t.json({ method: "GET", path: this.#path("/files/stat"), query: { path } });
|
|
543
|
+
}
|
|
544
|
+
async exists(path) {
|
|
545
|
+
return (await this.stat(path)).exists;
|
|
546
|
+
}
|
|
547
|
+
async mkdir(path, options = {}) {
|
|
548
|
+
await this.t.json({
|
|
549
|
+
method: "POST",
|
|
550
|
+
path: this.#path("/files:mkdir"),
|
|
551
|
+
body: { path, ...options },
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
async remove(path, options = {}) {
|
|
555
|
+
return (await this.t.json({
|
|
556
|
+
method: "POST",
|
|
557
|
+
path: this.#path("/files:remove"),
|
|
558
|
+
body: { path, ...options },
|
|
559
|
+
})).removed;
|
|
560
|
+
}
|
|
561
|
+
async rename(from, to, options = {}) {
|
|
562
|
+
await this.t.json({
|
|
563
|
+
method: "POST",
|
|
564
|
+
path: this.#path("/files:rename"),
|
|
565
|
+
body: { from, to, ...options },
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
/** Copies a local file or directory into the sandbox. A directory travels as
|
|
569
|
+
* one gzipped tar and is unpacked in place. */
|
|
570
|
+
async upload(localPath, remotePath) {
|
|
571
|
+
const { stat, readFile } = await import("node:fs/promises");
|
|
572
|
+
const info = await stat(localPath);
|
|
573
|
+
if (info.isFile()) {
|
|
574
|
+
await this.write(remotePath, await readFile(localPath));
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
const { packDirectory } = await import("./tar.js");
|
|
578
|
+
const archive = await packDirectory(localPath);
|
|
579
|
+
const staging = `/tmp/.runtime-upload-${crypto.randomUUID()}.tar.gz`;
|
|
580
|
+
await this.write(staging, archive);
|
|
581
|
+
const result = await new Sandbox(this.t, { id: this.sandboxId }).exec([
|
|
582
|
+
"sh",
|
|
583
|
+
"-c",
|
|
584
|
+
'mkdir -p "$1" && tar -xzf "$2" -C "$1"; code=$?; rm -f "$2"; exit $code',
|
|
585
|
+
"sh",
|
|
586
|
+
remotePath,
|
|
587
|
+
staging,
|
|
588
|
+
]);
|
|
589
|
+
if (result.exitCode !== 0)
|
|
590
|
+
throw new CommandError(result);
|
|
591
|
+
}
|
|
592
|
+
/** Copies a file or directory out of the sandbox. */
|
|
593
|
+
async download(remotePath, localPath) {
|
|
594
|
+
const fs = await import("node:fs/promises");
|
|
595
|
+
const paths = await import("node:path");
|
|
596
|
+
const entry = await this.stat(remotePath);
|
|
597
|
+
if (!entry.exists)
|
|
598
|
+
throw new RuntimeError({
|
|
599
|
+
message: `${remotePath} does not exist.`,
|
|
600
|
+
code: "file_not_found",
|
|
601
|
+
status: 404,
|
|
602
|
+
});
|
|
603
|
+
if (entry.type !== "directory") {
|
|
604
|
+
await fs.mkdir(paths.dirname(localPath), { recursive: true });
|
|
605
|
+
await fs.writeFile(localPath, await this.read(remotePath));
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
const staging = `/tmp/.runtime-download-${crypto.randomUUID()}.tar.gz`;
|
|
609
|
+
const sandbox = new Sandbox(this.t, { id: this.sandboxId });
|
|
610
|
+
const packed = await sandbox.exec(["tar", "-czf", staging, "-C", remotePath, "."]);
|
|
611
|
+
if (packed.exitCode !== 0)
|
|
612
|
+
throw new CommandError(packed);
|
|
613
|
+
try {
|
|
614
|
+
const { unpackArchive } = await import("./tar.js");
|
|
615
|
+
await unpackArchive(await this.read(staging), localPath);
|
|
616
|
+
}
|
|
617
|
+
finally {
|
|
618
|
+
await this.remove(staging).catch(() => undefined);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
export function sandboxPage(t, body, query) {
|
|
623
|
+
return new Page(body.data.map((info) => new Sandbox(t, info)), body.nextCursor, async (cursor) => sandboxPage(t, await t.json({
|
|
624
|
+
method: "GET",
|
|
625
|
+
path: "/v1/sandboxes",
|
|
626
|
+
query: Object.assign({}, query, { cursor }),
|
|
627
|
+
}), query));
|
|
628
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Page } from "./page.js";
|
|
2
|
+
import type { RequestOptions, Transport } from "./transport.js";
|
|
3
|
+
/** A sandbox's whole machine (files, memory, running processes), kept on its
|
|
4
|
+
* host to start new sandboxes from. */
|
|
5
|
+
export type Snapshot = {
|
|
6
|
+
id: string;
|
|
7
|
+
kind: "snapshot";
|
|
8
|
+
status: string;
|
|
9
|
+
state: "capturing" | "ready" | "failed" | "deleting" | "deleted";
|
|
10
|
+
name: string | null;
|
|
11
|
+
sourceSandboxId: string;
|
|
12
|
+
shape: {
|
|
13
|
+
vcpu: number;
|
|
14
|
+
memoryMiB: number;
|
|
15
|
+
diskMiB: number;
|
|
16
|
+
memoryGuarantee: string;
|
|
17
|
+
};
|
|
18
|
+
retentionDays: number;
|
|
19
|
+
/** What it stores, and what storage is metered for: the bytes it alone holds. */
|
|
20
|
+
storedBytes: number | null;
|
|
21
|
+
meteredBytes: number | null;
|
|
22
|
+
error: string | null;
|
|
23
|
+
createdAt: string;
|
|
24
|
+
readyAt: string | null;
|
|
25
|
+
expiresAt: string;
|
|
26
|
+
[key: string]: unknown;
|
|
27
|
+
};
|
|
28
|
+
export type SnapshotOptions = {
|
|
29
|
+
name?: string;
|
|
30
|
+
labels?: Record<string, string>;
|
|
31
|
+
retentionDays?: number;
|
|
32
|
+
};
|
|
33
|
+
/** `runtime.snapshots`. Take one with `sbx.snapshot()`, start from one with
|
|
34
|
+
* `runtime.sandboxes.create({ snapshot: id })`, or do both with `sbx.fork()`. */
|
|
35
|
+
export declare class Snapshots {
|
|
36
|
+
private readonly t;
|
|
37
|
+
constructor(t: Transport);
|
|
38
|
+
create(input: {
|
|
39
|
+
sandboxId: string;
|
|
40
|
+
} & SnapshotOptions, options?: RequestOptions): Promise<Snapshot>;
|
|
41
|
+
get(id: string, options?: RequestOptions): Promise<Snapshot>;
|
|
42
|
+
list(filter?: {
|
|
43
|
+
sandboxId?: string;
|
|
44
|
+
name?: string;
|
|
45
|
+
state?: "capturing" | "ready" | "failed" | "deleting";
|
|
46
|
+
limit?: number;
|
|
47
|
+
}): Promise<Page<Snapshot>>;
|
|
48
|
+
delete(id: string, options?: RequestOptions): Promise<void>;
|
|
49
|
+
extend(id: string, retentionDays: number, options?: RequestOptions): Promise<Snapshot>;
|
|
50
|
+
}
|