agental 0.1.0 → 0.2.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 +5 -536
- package/bin/agental.js +1 -1
- package/dist/cli.js +20 -20
- package/package.json +8 -26
- package/dist/client.d.ts +0 -321
- package/dist/client.js +0 -451
- package/dist/errors.d.ts +0 -62
- package/dist/errors.js +0 -65
- package/dist/http.d.ts +0 -69
- package/dist/http.js +0 -246
- package/dist/index.d.ts +0 -10
- package/dist/index.js +0 -5
- package/dist/openapi.d.ts +0 -17070
- package/dist/openapi.js +0 -5
- package/dist/sandbox.d.ts +0 -605
- package/dist/sandbox.js +0 -846
- package/dist/template.d.ts +0 -183
- package/dist/template.js +0 -261
- package/dist/types.d.ts +0 -198
- package/dist/types.js +0 -1
- package/dist/version.d.ts +0 -2
- package/dist/version.js +0 -2
- package/dist/webhooks.d.ts +0 -6
- package/dist/webhooks.js +0 -16
package/dist/sandbox.js
DELETED
|
@@ -1,846 +0,0 @@
|
|
|
1
|
-
import { AgentalError, ApiError, ConnectionError, TimeoutError } from "./errors.js";
|
|
2
|
-
import { sleep, tagSegment } from "./http.js";
|
|
3
|
-
import { Secrets } from "./template.js";
|
|
4
|
-
/** A create answers when the machine is ready, usually in under a minute. The API says to allow it three. */
|
|
5
|
-
const CREATE_TIMEOUT_MS = 180_000;
|
|
6
|
-
/** An archived sandbox is brought back in as long as its disk is large. */
|
|
7
|
-
const RESTORE_TIMEOUT_MS = 600_000;
|
|
8
|
-
/** No command sent through the API runs longer than ten minutes. */
|
|
9
|
-
const COMMAND_TIMEOUT_MS = 660_000;
|
|
10
|
-
function argv(command) {
|
|
11
|
-
return typeof command === "string" ? ["sh", "-c", command] : [...command];
|
|
12
|
-
}
|
|
13
|
-
function made(prefix) {
|
|
14
|
-
return `${prefix}-${crypto.randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
|
15
|
-
}
|
|
16
|
-
/** The burst refusals a wait may help: the start costs nothing once the meter has fallen. */
|
|
17
|
-
const WAITABLE_BURST = new Set(["burst_fee_too_high", "burst_budget_reached", "burst_in_free_month"]);
|
|
18
|
-
/** The header a start is sent with to cap what it pays in burst fees, when one is asked for. */
|
|
19
|
-
function burstHeaders(options) {
|
|
20
|
-
const fee = options.maxBurstFeeMicros;
|
|
21
|
-
if (fee === undefined) {
|
|
22
|
-
return {};
|
|
23
|
-
}
|
|
24
|
-
if (!Number.isSafeInteger(fee) || fee < 0) {
|
|
25
|
-
throw new AgentalError(`maxBurstFeeMicros is a whole number of millionths of a US dollar, 0 or more, not ${fee}.`);
|
|
26
|
-
}
|
|
27
|
-
return { "Agental-Max-Burst-Fee": String(fee) };
|
|
28
|
-
}
|
|
29
|
-
/** What a start paid in burst fees, as its answer says, or null where it does not. */
|
|
30
|
-
function burstFeeOf(headers) {
|
|
31
|
-
const value = headers.get("agental-burst-fee");
|
|
32
|
-
return value !== null && /^\d+$/.test(value) ? Number(value) : null;
|
|
33
|
-
}
|
|
34
|
-
/**
|
|
35
|
-
* Does a start, and when it is refused for its burst and options allow it, waits until it
|
|
36
|
-
* would cost nothing and does it once more.
|
|
37
|
-
*/
|
|
38
|
-
async function burstWaited(options, start) {
|
|
39
|
-
try {
|
|
40
|
-
return await start();
|
|
41
|
-
}
|
|
42
|
-
catch (error) {
|
|
43
|
-
const max = options.maxBurstWaitMs;
|
|
44
|
-
if (max === undefined ||
|
|
45
|
-
!(error instanceof ApiError) ||
|
|
46
|
-
!WAITABLE_BURST.has(error.code) ||
|
|
47
|
-
typeof error.retryAfterSeconds !== "number" ||
|
|
48
|
-
error.retryAfterSeconds * 1000 > max) {
|
|
49
|
-
throw error;
|
|
50
|
-
}
|
|
51
|
-
await sleep(error.retryAfterSeconds * 1000);
|
|
52
|
-
return await start();
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
function iso(when) {
|
|
56
|
-
return when instanceof Date ? when.toISOString() : when;
|
|
57
|
-
}
|
|
58
|
-
function toBase64(bytes) {
|
|
59
|
-
let binary = "";
|
|
60
|
-
for (let at = 0; at < bytes.length; at += 0x8000) {
|
|
61
|
-
binary += String.fromCharCode(...bytes.subarray(at, at + 0x8000));
|
|
62
|
-
}
|
|
63
|
-
return btoa(binary);
|
|
64
|
-
}
|
|
65
|
-
function fromBase64(text) {
|
|
66
|
-
return Uint8Array.from(atob(text), (character) => character.charCodeAt(0));
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* Asks for a sandbox, by a create or a fork, and waits until it is running. A 202 means the
|
|
70
|
-
* machine was asked for and is not confirmed yet, and so does an uncertain operation: the
|
|
71
|
-
* same request is sent again with the same key until it answers with the sandbox.
|
|
72
|
-
*/
|
|
73
|
-
export async function makeSandbox(http, path, body, key, timeoutMs, burst = {}) {
|
|
74
|
-
const headers = { "Idempotency-Key": key, ...burstHeaders(burst) };
|
|
75
|
-
return await burstWaited(burst, async () => {
|
|
76
|
-
const deadline = Date.now() + timeoutMs;
|
|
77
|
-
// The first answer says what the start paid. One sent again with the same key started nothing more.
|
|
78
|
-
let burstFeeMicros = null;
|
|
79
|
-
for (;;) {
|
|
80
|
-
try {
|
|
81
|
-
const answer = await http.json({ method: "POST", path, body, headers, replay: true });
|
|
82
|
-
burstFeeMicros ??= burstFeeOf(answer.headers);
|
|
83
|
-
if (answer.status !== 202) {
|
|
84
|
-
return { info: answer.body, burstFeeMicros };
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
catch (error) {
|
|
88
|
-
if (!(error instanceof ApiError && error.code === "uncertain_operation")) {
|
|
89
|
-
throw error;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
if (Date.now() + http.pollIntervalMs > deadline) {
|
|
93
|
-
throw new TimeoutError(`The sandbox was not running after ${timeoutMs} ms. Ask again with idempotencyKey "${key}" to get the same one.`);
|
|
94
|
-
}
|
|
95
|
-
await sleep(http.pollIntervalMs);
|
|
96
|
-
}
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
/** The files in a sandbox's work folder, /opt/agental. Paths are relative to it. */
|
|
100
|
-
export class Files {
|
|
101
|
-
http;
|
|
102
|
-
path;
|
|
103
|
-
constructor(http, path) {
|
|
104
|
-
this.http = http;
|
|
105
|
-
this.path = path;
|
|
106
|
-
}
|
|
107
|
-
/** The text of a file. One that is not UTF-8 is refused: read it with readBytes. */
|
|
108
|
-
async read(path) {
|
|
109
|
-
const { body } = await this.http.json({ method: "GET", path: this.path, query: { path }, replay: true });
|
|
110
|
-
return body.text ?? "";
|
|
111
|
-
}
|
|
112
|
-
async readBytes(path) {
|
|
113
|
-
const { body } = await this.http.json({ method: "GET", path: this.path, query: { path, encoding: "base64" }, replay: true });
|
|
114
|
-
return fromBase64(body.base64 ?? "");
|
|
115
|
-
}
|
|
116
|
-
/** Writes a file of up to 10 MB, making the folders it needs. */
|
|
117
|
-
async write(path, contents) {
|
|
118
|
-
const body = typeof contents === "string" ? { path, text: contents } : { path, base64: toBase64(contents) };
|
|
119
|
-
return (await this.http.json({ method: "PUT", path: this.path, body, replay: true })).body;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
/**
|
|
123
|
-
* The sandbox's browser, driven from outside it over the Chrome DevTools Protocol: Chrome in
|
|
124
|
-
* its machine, which Playwright's chromium.connectOverCDP(cdpUrl), Puppeteer's
|
|
125
|
-
* puppeteer.connect({ browserWSEndpoint: cdpUrl }), browser-use and Stagehand on your own
|
|
126
|
-
* computer connect to.
|
|
127
|
-
*/
|
|
128
|
-
export class Browser {
|
|
129
|
-
http;
|
|
130
|
-
path;
|
|
131
|
-
constructor(http, path) {
|
|
132
|
-
this.http = http;
|
|
133
|
-
this.path = path;
|
|
134
|
-
}
|
|
135
|
-
/**
|
|
136
|
-
* Turns the browser on and says where to connect. Keep cdpUrl as a password is kept:
|
|
137
|
-
* whoever has it has the browser, and what it is signed in to. Chrome is on the desktop
|
|
138
|
-
* when one is on, and has no window when none is, unless headless says which. Asked
|
|
139
|
-
* otherwise than it is, Chrome is started again so, which closes what was open in it.
|
|
140
|
-
*/
|
|
141
|
-
async on(options = {}) {
|
|
142
|
-
const body = options.headless === undefined ? undefined : { headless: options.headless };
|
|
143
|
-
return (await this.http.json({ method: "PUT", path: this.path, ...(body ? { body } : {}), replay: true })).body;
|
|
144
|
-
}
|
|
145
|
-
/** Whether it is on, whether it is running, and where to connect. It changes nothing. */
|
|
146
|
-
async get() {
|
|
147
|
-
return (await this.http.json({ method: "GET", path: this.path, replay: true })).body;
|
|
148
|
-
}
|
|
149
|
-
/** Turns it off. Its cdpUrl leads nowhere from then on, and what was connected through it is cut. What Chrome remembers stays on the sandbox's disk. */
|
|
150
|
-
async off() {
|
|
151
|
-
return (await this.http.json({ method: "DELETE", path: this.path, replay: true })).body;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
/**
|
|
155
|
-
* SSH into the sandbox: a port of its own on its server, root as the user, and the keys
|
|
156
|
-
* that may log in. The CLI's `agental ssh` does all of it in one go.
|
|
157
|
-
*/
|
|
158
|
-
export class Ssh {
|
|
159
|
-
http;
|
|
160
|
-
path;
|
|
161
|
-
constructor(http, path) {
|
|
162
|
-
this.http = http;
|
|
163
|
-
this.path = path;
|
|
164
|
-
}
|
|
165
|
-
/** Turns SSH on, and says where to connect: host, port, user, and the host key to pin. Turning it on again changes nothing. */
|
|
166
|
-
async on() {
|
|
167
|
-
return (await this.http.json({ method: "PUT", path: this.path, replay: true })).body;
|
|
168
|
-
}
|
|
169
|
-
/** Whether SSH is on, and where to connect while it is. It changes nothing, whatever state the sandbox is in. */
|
|
170
|
-
async get() {
|
|
171
|
-
return (await this.http.json({ method: "GET", path: this.path, replay: true })).body;
|
|
172
|
-
}
|
|
173
|
-
/** Turns SSH off: its port is closed, which cuts whoever is connected. The keys in the sandbox are left as they are. */
|
|
174
|
-
async off() {
|
|
175
|
-
return (await this.http.json({ method: "DELETE", path: this.path, replay: true })).body;
|
|
176
|
-
}
|
|
177
|
-
/** The keys that may log in as root, as the machine's authorized keys file holds them, whoever put them there. The sandbox must be running. */
|
|
178
|
-
async keys() {
|
|
179
|
-
return (await this.http.json({ method: "GET", path: `${this.path}/keys`, replay: true })).body.keys;
|
|
180
|
-
}
|
|
181
|
-
/** Lets a public key, one line as in an authorized keys file, log in as root. The sandbox must be running. */
|
|
182
|
-
async addKey(publicKey) {
|
|
183
|
-
return (await this.http.json({ method: "POST", path: `${this.path}/keys`, body: { publicKey }, replay: true })).body;
|
|
184
|
-
}
|
|
185
|
-
/**
|
|
186
|
-
* Lets a key, by the id keys() gives it, log in no longer. Whoever is logged in with it stays until they leave.
|
|
187
|
-
* A key that is not there answers not_found, as a second try does after a first whose answer was lost.
|
|
188
|
-
*/
|
|
189
|
-
async removeKey(keyId) {
|
|
190
|
-
await this.http.send({ method: "DELETE", path: `${this.path}/keys/${encodeURIComponent(keyId)}`, replay: true });
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
/**
|
|
194
|
-
* The sandbox's desktop: a screen in its machine that programs open windows on, with
|
|
195
|
-
* DISPLAY set to what display says, and that is watched and used in a browser at url.
|
|
196
|
-
*/
|
|
197
|
-
export class Desktop {
|
|
198
|
-
http;
|
|
199
|
-
path;
|
|
200
|
-
constructor(http, path) {
|
|
201
|
-
this.http = http;
|
|
202
|
-
this.path = path;
|
|
203
|
-
}
|
|
204
|
-
/**
|
|
205
|
-
* Turns the desktop on and says where it is watched. Keep url as a password is kept:
|
|
206
|
-
* whoever has it sees the screen and can click and type on it. The screen is 1280 by 800
|
|
207
|
-
* unless a size is given, or it already has another. Given another size than it has, it
|
|
208
|
-
* is started again at that size, which closes what was open on it.
|
|
209
|
-
*/
|
|
210
|
-
async on(size) {
|
|
211
|
-
return (await this.http.json({ method: "PUT", path: this.path, ...(size ? { body: size } : {}), replay: true })).body;
|
|
212
|
-
}
|
|
213
|
-
/** Whether it is on, whether it is running, and where it is watched. It changes nothing. */
|
|
214
|
-
async get() {
|
|
215
|
-
return (await this.http.json({ method: "GET", path: this.path, replay: true })).body;
|
|
216
|
-
}
|
|
217
|
-
/** Turns it off. Its url leads nowhere from then on, and what was open on it is closed. */
|
|
218
|
-
async off() {
|
|
219
|
-
return (await this.http.json({ method: "DELETE", path: this.path, replay: true })).body;
|
|
220
|
-
}
|
|
221
|
-
/**
|
|
222
|
-
* A picture of the screen as it is now: the bytes of a PNG of all of it, with the pointer
|
|
223
|
-
* on it, unless the options ask for a JPEG, a part of the screen, a smaller picture or no
|
|
224
|
-
* pointer. Each of those makes it cheaper for a model to read. It changes nothing.
|
|
225
|
-
*/
|
|
226
|
-
async screenshot(options = {}) {
|
|
227
|
-
return fromBase64((await this.screenshotBase64(options)).base64);
|
|
228
|
-
}
|
|
229
|
-
/**
|
|
230
|
-
* The same picture as screenshot, as the API gives it in JSON: in base64, which is what a
|
|
231
|
-
* model is handed, with its media type, its width and height, and the part of the screen
|
|
232
|
-
* it shows. A point of the picture is at region.x + x * region.width / width on the screen.
|
|
233
|
-
*/
|
|
234
|
-
async screenshotBase64(options = {}) {
|
|
235
|
-
const { region, format, quality, scale, cursor } = options;
|
|
236
|
-
const query = {
|
|
237
|
-
encoding: "base64",
|
|
238
|
-
format,
|
|
239
|
-
quality: quality === undefined ? undefined : String(quality),
|
|
240
|
-
scale: scale === undefined ? undefined : String(scale),
|
|
241
|
-
cursor: cursor === undefined ? undefined : String(cursor),
|
|
242
|
-
...(region ? { x: String(region.x), y: String(region.y), width: String(region.width), height: String(region.height) } : {}),
|
|
243
|
-
};
|
|
244
|
-
return (await this.http.json({ method: "GET", path: `${this.path}/screenshot`, query, replay: true })).body;
|
|
245
|
-
}
|
|
246
|
-
/** The text on the desktop's clipboard: what a paste there would put in. "" when it holds none. It changes nothing. */
|
|
247
|
-
async clipboard() {
|
|
248
|
-
return (await this.http.json({ method: "GET", path: `${this.path}/clipboard`, replay: true })).body.text;
|
|
249
|
-
}
|
|
250
|
-
/**
|
|
251
|
-
* Puts text on the desktop's clipboard, up to 1 MB, in place of what was there. It pastes
|
|
252
|
-
* nothing: key("ctrl+v") pastes it at once where the keyboard's focus is, where type would
|
|
253
|
-
* type it a key at a time.
|
|
254
|
-
*/
|
|
255
|
-
async setClipboard(text) {
|
|
256
|
-
await this.http.send({ method: "PUT", path: `${this.path}/clipboard`, body: { text }, replay: true });
|
|
257
|
-
}
|
|
258
|
-
/**
|
|
259
|
-
* Makes a link to the desktop at an address of its own: to watch the screen only, with
|
|
260
|
-
* access "view", or to use it as well, with "control". It leads nowhere after expiresIn
|
|
261
|
-
* seconds, ten minutes unless given, and what was opened through it is closed then unless
|
|
262
|
-
* cutOnExpiry is false. The desktop's own url stays as it is. A link is not made again
|
|
263
|
-
* by itself when no answer comes: list the links to see whether it was made.
|
|
264
|
-
*/
|
|
265
|
-
async createLink(options) {
|
|
266
|
-
return (await this.http.json({ method: "POST", path: `${this.path}/links`, body: options })).body;
|
|
267
|
-
}
|
|
268
|
-
/** The links to the desktop that have not expired, the oldest first. */
|
|
269
|
-
async links() {
|
|
270
|
-
return (await this.http.json({ method: "GET", path: `${this.path}/links`, replay: true })).body.links;
|
|
271
|
-
}
|
|
272
|
-
/** Takes a link away before it expires: it leads nowhere from then on, and what was opened through it is closed. */
|
|
273
|
-
async deleteLink(linkId) {
|
|
274
|
-
await this.http.send({ method: "DELETE", path: `${this.path}/links/${encodeURIComponent(linkId)}` });
|
|
275
|
-
}
|
|
276
|
-
/**
|
|
277
|
-
* Moves the pointer to a point of the screen and presses a button there: the left one
|
|
278
|
-
* once, unless button or count say otherwise. What uses the pointer or the keyboard is
|
|
279
|
-
* not sent again by itself: a click that was cut off may have been made.
|
|
280
|
-
*/
|
|
281
|
-
async click(x, y, options = {}) {
|
|
282
|
-
await this.use("click", { x, y, ...options });
|
|
283
|
-
}
|
|
284
|
-
/** Moves the pointer to a point of the screen, and presses nothing. */
|
|
285
|
-
async move(x, y) {
|
|
286
|
-
await this.use("move", { x, y });
|
|
287
|
-
}
|
|
288
|
-
/** Presses a button at one point, moves the pointer to another with it held, and lets go there. */
|
|
289
|
-
async drag(from, to, options = {}) {
|
|
290
|
-
await this.use("drag", { from, to, ...options });
|
|
291
|
-
}
|
|
292
|
-
/** Turns the wheel over a point: dy notches down, or up when it is negative, and dx to the right, or to the left. */
|
|
293
|
-
async scroll(x, y, turn) {
|
|
294
|
-
await this.use("scroll", { x, y, ...turn });
|
|
295
|
-
}
|
|
296
|
-
/** Types text where the keyboard's focus is, a key at a time. Click the field first. */
|
|
297
|
-
async type(text) {
|
|
298
|
-
await this.use("type", { text });
|
|
299
|
-
}
|
|
300
|
-
/**
|
|
301
|
-
* Presses keys, named as xdotool names them: "Return", "Escape", "Page_Down". Keys held
|
|
302
|
-
* together have + between them, "ctrl+l", and several with spaces between are pressed
|
|
303
|
-
* one after another, "ctrl+a ctrl+c".
|
|
304
|
-
*/
|
|
305
|
-
async key(keys) {
|
|
306
|
-
await this.use("key", { keys });
|
|
307
|
-
}
|
|
308
|
-
async use(what, body) {
|
|
309
|
-
await this.http.send({ method: "POST", path: `${this.path}/${what}`, body });
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
/** Commands left running in the background: started, and looked at later. */
|
|
313
|
-
export class Commands {
|
|
314
|
-
http;
|
|
315
|
-
path;
|
|
316
|
-
constructor(http, path) {
|
|
317
|
-
this.http = http;
|
|
318
|
-
this.path = path;
|
|
319
|
-
}
|
|
320
|
-
/** Starts a command and answers at once. It may run for up to ten minutes. */
|
|
321
|
-
async start(command, options = {}) {
|
|
322
|
-
const { body } = await this.http.json({
|
|
323
|
-
method: "POST",
|
|
324
|
-
path: this.path,
|
|
325
|
-
body: { command: argv(command), ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }) },
|
|
326
|
-
...(options.signal ? { signal: options.signal } : {}),
|
|
327
|
-
});
|
|
328
|
-
return await this.get(body.id);
|
|
329
|
-
}
|
|
330
|
-
/** A command's status, running, succeeded, failed or timed_out, with its exit code and its output so far. */
|
|
331
|
-
async get(commandId) {
|
|
332
|
-
return (await this.http.json({ method: "GET", path: `${this.path}/${encodeURIComponent(commandId)}`, replay: true })).body;
|
|
333
|
-
}
|
|
334
|
-
/** Reads a command until it has ended. */
|
|
335
|
-
async wait(commandId, options = {}) {
|
|
336
|
-
const timeoutMs = options.timeoutMs ?? COMMAND_TIMEOUT_MS;
|
|
337
|
-
const deadline = Date.now() + timeoutMs;
|
|
338
|
-
for (;;) {
|
|
339
|
-
const command = await this.get(commandId);
|
|
340
|
-
if (command.status !== "running") {
|
|
341
|
-
return command;
|
|
342
|
-
}
|
|
343
|
-
if (Date.now() + this.http.pollIntervalMs > deadline) {
|
|
344
|
-
throw new TimeoutError(`Command ${commandId} was still running after ${timeoutMs} ms.`);
|
|
345
|
-
}
|
|
346
|
-
await sleep(this.http.pollIntervalMs);
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
/**
|
|
350
|
-
* Starts a command and waits for it by reading it, so no request stays open while it
|
|
351
|
-
* runs. Use it for one that takes more than a minute: a request held open that long may
|
|
352
|
-
* be cut by a proxy on the way.
|
|
353
|
-
*/
|
|
354
|
-
async run(command, options = {}) {
|
|
355
|
-
const started = await this.start(command, options);
|
|
356
|
-
return started.status === "running" ? await this.wait(started.id) : started;
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
/**
|
|
360
|
-
* Commands the sandbox runs on a schedule: every so many minutes, hours or days, or as a cron
|
|
361
|
-
* expression says in a time zone. The API keeps them, so a run that finds the sandbox stopped
|
|
362
|
-
* starts it, by default, and stops it again when the command ends, keeping its disk. A run
|
|
363
|
-
* that started it leaves it running if someone else began to use it meanwhile.
|
|
364
|
-
*/
|
|
365
|
-
export class Schedules {
|
|
366
|
-
http;
|
|
367
|
-
path;
|
|
368
|
-
constructor(http, path) {
|
|
369
|
-
this.http = http;
|
|
370
|
-
this.path = path;
|
|
371
|
-
}
|
|
372
|
-
/** The sandbox's schedules, oldest first, each with its last run. */
|
|
373
|
-
async list() {
|
|
374
|
-
return (await this.http.json({ method: "GET", path: this.path, replay: true })).body.schedules;
|
|
375
|
-
}
|
|
376
|
-
/**
|
|
377
|
-
* Makes a schedule: what it runs, through sh -c, and every ("15m", "1h", "1d"), cron
|
|
378
|
-
* ("0 7 * * 1-5", in timeZone), or at, once ("2026-11-04T09:00:00Z"). A run gets 600000 ms unless timeoutMs says, up to six hours.
|
|
379
|
-
* One that may start the sandbox needs a key that may start and stop it; ifStopped "skip"
|
|
380
|
-
* runs only while it runs.
|
|
381
|
-
*/
|
|
382
|
-
async create(schedule) {
|
|
383
|
-
return (await this.http.json({ method: "POST", path: this.path, body: schedule })).body;
|
|
384
|
-
}
|
|
385
|
-
/** A schedule, with its next run and its last. */
|
|
386
|
-
async get(scheduleId) {
|
|
387
|
-
return (await this.http.json({ method: "GET", path: `${this.path}/${encodeURIComponent(scheduleId)}`, replay: true })).body;
|
|
388
|
-
}
|
|
389
|
-
/** Changes a schedule. What is left out is as it was; enabled false pauses it. */
|
|
390
|
-
async update(scheduleId, changes) {
|
|
391
|
-
return (await this.http.json({ method: "PATCH", path: `${this.path}/${encodeURIComponent(scheduleId)}`, body: changes, replay: true })).body;
|
|
392
|
-
}
|
|
393
|
-
/** Deletes a schedule and its runs. A run under way goes on to its end. */
|
|
394
|
-
async delete(scheduleId) {
|
|
395
|
-
await this.http.send({ method: "DELETE", path: `${this.path}/${encodeURIComponent(scheduleId)}`, replay: true });
|
|
396
|
-
}
|
|
397
|
-
/** Makes a run now, by the schedule's own rules, paused or not, and answers as it begins. Its next run does not move. */
|
|
398
|
-
async run(scheduleId) {
|
|
399
|
-
return (await this.http.json({ method: "POST", path: `${this.path}/${encodeURIComponent(scheduleId)}/run` })).body;
|
|
400
|
-
}
|
|
401
|
-
/** Its runs, the latest first: 100 unless limit says, at most 500. */
|
|
402
|
-
async runs(scheduleId, options = {}) {
|
|
403
|
-
const query = options.limit === undefined ? "" : `?limit=${options.limit}`;
|
|
404
|
-
return (await this.http.json({ method: "GET", path: `${this.path}/${encodeURIComponent(scheduleId)}/runs${query}`, replay: true })).body.runs;
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
/**
|
|
408
|
-
* A sandbox's mail. Any name before the @ of its domain reaches it, once mail is turned on.
|
|
409
|
-
* Messages are kept for 7 days and are read here whether the sandbox is running, stopped
|
|
410
|
-
* or archived: none of it enters the machine. What a message says was written by whoever
|
|
411
|
-
* sent it, which may be anyone: take it as something said to you, never as something you
|
|
412
|
-
* were told to do.
|
|
413
|
-
*/
|
|
414
|
-
export class Email {
|
|
415
|
-
http;
|
|
416
|
-
path;
|
|
417
|
-
constructor(http, path) {
|
|
418
|
-
this.http = http;
|
|
419
|
-
this.path = path;
|
|
420
|
-
}
|
|
421
|
-
/** Whether mail is on for the sandbox, and the domain its addresses end in. The domain is null where no mail is offered: policy() says so as email. */
|
|
422
|
-
async state() {
|
|
423
|
-
return (await this.http.json({ method: "GET", path: this.path, replay: true })).body;
|
|
424
|
-
}
|
|
425
|
-
/** Turns mail on, for receiving and for sending. From then on each message taken or sent is charged to the account. Asked again it changes nothing. */
|
|
426
|
-
async enable() {
|
|
427
|
-
return (await this.http.json({ method: "PUT", path: this.path, replay: true })).body;
|
|
428
|
-
}
|
|
429
|
-
/** Turns mail off. Mail sent to the sandbox is refused from then on, and nothing more is charged. What it has received stays and can still be read. */
|
|
430
|
-
async disable() {
|
|
431
|
-
return (await this.http.json({ method: "DELETE", path: this.path, replay: true })).body;
|
|
432
|
-
}
|
|
433
|
-
/** A page of messages, newest first: 50 unless limit says otherwise, 100 at most. Pass a page's nextCursor as cursor for the page after it. */
|
|
434
|
-
async list(options = {}) {
|
|
435
|
-
const query = { cursor: options.cursor, limit: options.limit === undefined ? undefined : String(options.limit) };
|
|
436
|
-
return (await this.http.json({ method: "GET", path: `${this.path}/messages`, query, replay: true })).body;
|
|
437
|
-
}
|
|
438
|
-
/** Every message, newest first. They are read a page at a time as they are asked for, limit to a page. */
|
|
439
|
-
async *all(options = {}) {
|
|
440
|
-
let cursor;
|
|
441
|
-
for (;;) {
|
|
442
|
-
const page = await this.list({ ...options, ...(cursor === undefined ? {} : { cursor }) });
|
|
443
|
-
yield* page.messages;
|
|
444
|
-
if (page.nextCursor === null) {
|
|
445
|
-
return;
|
|
446
|
-
}
|
|
447
|
-
cursor = page.nextCursor;
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
/** One message, with its headers and what it says. For one that was sent, state says where it has got to. */
|
|
451
|
-
async get(messageId) {
|
|
452
|
-
return (await this.http.json({ method: "GET", path: this.message(messageId), replay: true })).body;
|
|
453
|
-
}
|
|
454
|
-
/** The message exactly as it was transmitted, attachments included. None of it is scanned for anything. */
|
|
455
|
-
async raw(messageId) {
|
|
456
|
-
const response = await this.http.send({ method: "GET", path: `${this.message(messageId)}/raw`, headers: { Accept: "message/rfc822" }, replay: true });
|
|
457
|
-
return new Uint8Array(await response.arrayBuffer());
|
|
458
|
-
}
|
|
459
|
-
/** Deletes one message. What it counted for, in the day's limits and on the bill, stays counted. */
|
|
460
|
-
async delete(messageId) {
|
|
461
|
-
await this.http.send({ method: "DELETE", path: this.message(messageId) });
|
|
462
|
-
}
|
|
463
|
-
/**
|
|
464
|
-
* Sends one message, as plain text, to the address of the sandbox's own account, which
|
|
465
|
-
* is the only address a sandbox may write to. Mail has to be on for the sandbox. The
|
|
466
|
-
* message is charged when it is taken, whatever becomes of it: get() it later to see
|
|
467
|
-
* where it has got to. If the answer is lost, the send is asked again with the same
|
|
468
|
-
* key, which gives the same message back and never sends a second one.
|
|
469
|
-
*/
|
|
470
|
-
async send(message) {
|
|
471
|
-
const { idempotencyKey, ...body } = message;
|
|
472
|
-
return (await this.http.json({
|
|
473
|
-
method: "POST",
|
|
474
|
-
path: `${this.path}/messages`,
|
|
475
|
-
body,
|
|
476
|
-
headers: { "Idempotency-Key": idempotencyKey ?? made("sdk") },
|
|
477
|
-
replay: true,
|
|
478
|
-
})).body;
|
|
479
|
-
}
|
|
480
|
-
message(messageId) {
|
|
481
|
-
return `${this.path}/messages/${encodeURIComponent(messageId)}`;
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
/** One sandbox: a Linux machine of your own. */
|
|
485
|
-
export class Sandbox {
|
|
486
|
-
http;
|
|
487
|
-
files;
|
|
488
|
-
commands;
|
|
489
|
-
schedules;
|
|
490
|
-
email;
|
|
491
|
-
desktop;
|
|
492
|
-
browser;
|
|
493
|
-
ssh;
|
|
494
|
-
/** Its own secrets, on top of its template's. A change is used from its next command, without a restart. */
|
|
495
|
-
secrets;
|
|
496
|
-
/** The sandbox as it was last read. refresh() reads it again. */
|
|
497
|
-
info;
|
|
498
|
-
/**
|
|
499
|
-
* What the last create, fork or start made through this object paid in burst fees, in
|
|
500
|
-
* millionths of a US dollar, as the API's Agental-Burst-Fee header said. Null before one,
|
|
501
|
-
* and where the API did not say.
|
|
502
|
-
*/
|
|
503
|
-
burstFeeMicros;
|
|
504
|
-
path;
|
|
505
|
-
constructor(http, info, burstFeeMicros = null) {
|
|
506
|
-
this.http = http;
|
|
507
|
-
this.info = info;
|
|
508
|
-
this.burstFeeMicros = burstFeeMicros;
|
|
509
|
-
this.path = `/v1/sandboxes/${encodeURIComponent(info.id)}`;
|
|
510
|
-
this.files = new Files(http, `${this.path}/files`);
|
|
511
|
-
this.commands = new Commands(http, `${this.path}/commands`);
|
|
512
|
-
this.schedules = new Schedules(http, `${this.path}/schedules`);
|
|
513
|
-
this.email = new Email(http, `${this.path}/email`);
|
|
514
|
-
this.desktop = new Desktop(http, `${this.path}/desktop`);
|
|
515
|
-
this.browser = new Browser(http, `${this.path}/browser`);
|
|
516
|
-
this.ssh = new Ssh(http, `${this.path}/ssh`);
|
|
517
|
-
this.secrets = new Secrets(http, `${this.path}/secrets`);
|
|
518
|
-
}
|
|
519
|
-
get id() {
|
|
520
|
-
return this.info.id;
|
|
521
|
-
}
|
|
522
|
-
get name() {
|
|
523
|
-
return this.info.name;
|
|
524
|
-
}
|
|
525
|
-
get size() {
|
|
526
|
-
return this.info.size;
|
|
527
|
-
}
|
|
528
|
-
/** As it was last read: running, stopped, or on its way to one of them. */
|
|
529
|
-
get status() {
|
|
530
|
-
return this.info.status;
|
|
531
|
-
}
|
|
532
|
-
async refresh() {
|
|
533
|
-
this.info = (await this.http.json({ method: "GET", path: this.path, replay: true })).body;
|
|
534
|
-
return this;
|
|
535
|
-
}
|
|
536
|
-
/**
|
|
537
|
-
* Changes its name, or how many seconds it runs after each start before it is stopped,
|
|
538
|
-
* null for the deployment's default. A ttlSeconds set while it runs puts its stop back to
|
|
539
|
-
* its last start and the new ttlSeconds, in place of one setAutoStop() moved. tags are put
|
|
540
|
-
* in place of its own, and metadata is merged into its own, null taking a key away: its
|
|
541
|
-
* template's stay, and are changed on the template.
|
|
542
|
-
*/
|
|
543
|
-
async update(changes) {
|
|
544
|
-
this.info = (await this.http.json({ method: "PATCH", path: this.path, body: changes, replay: true })).body;
|
|
545
|
-
return this;
|
|
546
|
-
}
|
|
547
|
-
/** Its tags as last read: its template's, then its own. ownTags are its own alone. */
|
|
548
|
-
get tags() {
|
|
549
|
-
return [...(this.info.tags ?? [])];
|
|
550
|
-
}
|
|
551
|
-
/** Its metadata as last read: its template's, with its own value for a key in place of the template's. */
|
|
552
|
-
get metadata() {
|
|
553
|
-
return { ...this.info.metadata };
|
|
554
|
-
}
|
|
555
|
-
/** Gives it a tag of its own. One it has already is left as it is. */
|
|
556
|
-
async addTag(tag) {
|
|
557
|
-
this.info = (await this.http.json({ method: "PUT", path: `${this.path}/tags/${tagSegment(tag)}`, replay: true })).body;
|
|
558
|
-
return this;
|
|
559
|
-
}
|
|
560
|
-
/**
|
|
561
|
-
* Takes a tag of its own away. One it does not have is nothing to take. One it has only
|
|
562
|
-
* from its template is refused with tag_from_template: take it off the template.
|
|
563
|
-
*/
|
|
564
|
-
async removeTag(tag) {
|
|
565
|
-
this.info = (await this.http.json({ method: "DELETE", path: `${this.path}/tags/${tagSegment(tag)}`, replay: true })).body;
|
|
566
|
-
return this;
|
|
567
|
-
}
|
|
568
|
-
/**
|
|
569
|
-
* When it is to be stopped, how many seconds that is from now, and what decided it: its
|
|
570
|
-
* last start and ttlSeconds, or a move of the stop. autoStopAt is null when it is not
|
|
571
|
-
* running, or has no time limit. info.autoStopAt follows, without another read.
|
|
572
|
-
*/
|
|
573
|
-
async autoStop() {
|
|
574
|
-
const stop = (await this.http.json({ method: "GET", path: `${this.path}/auto-stop`, replay: true })).body;
|
|
575
|
-
this.info = { ...this.info, autoStopAt: stop.autoStopAt, ttlSeconds: stop.ttlSeconds };
|
|
576
|
-
return stop;
|
|
577
|
-
}
|
|
578
|
-
/**
|
|
579
|
-
* Moves the stop of the run under way, later or sooner: { at } a time, a Date or an ISO
|
|
580
|
-
* 8601 string with its offset, or { inSeconds } that many seconds from now. It is 60
|
|
581
|
-
* seconds from now at the least, and maxTtlSeconds of policy() at the most, or it is
|
|
582
|
-
* refused with auto_stop_too_soon or auto_stop_too_far and the stop stays as it was. A
|
|
583
|
-
* sandbox that is not running is refused with invalid_state. It holds until the sandbox
|
|
584
|
-
* stops: its next start runs for ttlSeconds again. Moved again, the last move counts.
|
|
585
|
-
* Every move is the event sandbox.auto_stop_set.
|
|
586
|
-
*/
|
|
587
|
-
async setAutoStop(when) {
|
|
588
|
-
// Only what was given is sent: JSON has no undefined. A move whose answer is lost is sent
|
|
589
|
-
// again, which moves the stop to the same place, or for inSeconds a few seconds on.
|
|
590
|
-
const body = { ...when, at: iso(when.at) };
|
|
591
|
-
const stop = (await this.http.json({ method: "PUT", path: `${this.path}/auto-stop`, body, replay: true })).body;
|
|
592
|
-
this.info = { ...this.info, autoStopAt: stop.autoStopAt, ttlSeconds: stop.ttlSeconds };
|
|
593
|
-
return stop;
|
|
594
|
-
}
|
|
595
|
-
/**
|
|
596
|
-
* Takes its time limit away: it runs until it is stopped, this run and every start after.
|
|
597
|
-
* ttlSeconds goes back to the deployment's default, which is none unless it says otherwise,
|
|
598
|
-
* and a moved stop is gone. Running or stopped alike. A key needs sandbox.update for it.
|
|
599
|
-
*/
|
|
600
|
-
async clearAutoStop() {
|
|
601
|
-
const stop = (await this.http.json({ method: "DELETE", path: `${this.path}/auto-stop`, replay: true })).body;
|
|
602
|
-
this.info = { ...this.info, autoStopAt: stop.autoStopAt, ttlSeconds: stop.ttlSeconds };
|
|
603
|
-
return stop;
|
|
604
|
-
}
|
|
605
|
-
/** Which template it is on, and whether it has the template's secrets as they are now. */
|
|
606
|
-
async template() {
|
|
607
|
-
return (await this.http.json({ method: "GET", path: `${this.path}/template`, replay: true })).body;
|
|
608
|
-
}
|
|
609
|
-
/**
|
|
610
|
-
* Puts it on another template of your account. It has that template's variables and
|
|
611
|
-
* files from its next command, without a restart. Its size, its disk and its own secrets stay.
|
|
612
|
-
*/
|
|
613
|
-
async setTemplate(template) {
|
|
614
|
-
const templateId = typeof template === "string" ? template : template.id;
|
|
615
|
-
const set = (await this.http.json({ method: "PUT", path: `${this.path}/template`, body: { templateId }, replay: true })).body;
|
|
616
|
-
this.info = { ...this.info, templateId: set.templateId };
|
|
617
|
-
return set;
|
|
618
|
-
}
|
|
619
|
-
/**
|
|
620
|
-
* What it may reach outside itself: the rule it is held to, outbound and allow, and what
|
|
621
|
-
* that comes from: its template's rule as it has it, and its own addresses on top.
|
|
622
|
-
*/
|
|
623
|
-
async network() {
|
|
624
|
-
const detail = (await this.http.json({ method: "GET", path: `${this.path}/network`, replay: true })).body;
|
|
625
|
-
this.info = { ...this.info, network: { outbound: detail.outbound, allow: detail.allow } };
|
|
626
|
-
return detail;
|
|
627
|
-
}
|
|
628
|
-
/**
|
|
629
|
-
* Sets the addresses it may reach on top of its template's outbound rule, in place of the
|
|
630
|
-
* ones it had: { allow: ["192.0.2.99"] }, or { allow: [] } to take them away. They add to
|
|
631
|
-
* an allowlist, let it out to them alone on a blocked template, and change nothing while
|
|
632
|
-
* the template is open. The rule itself is the template's: Template.setNetwork(). It holds
|
|
633
|
-
* from when this answers, without a restart, and what is sent to the sandbox is not
|
|
634
|
-
* affected. Only where policy() says networkPolicy.
|
|
635
|
-
*/
|
|
636
|
-
async setNetwork(own) {
|
|
637
|
-
const detail = (await this.http.json({ method: "PUT", path: `${this.path}/network`, body: own, replay: true })).body;
|
|
638
|
-
this.info = { ...this.info, network: { outbound: detail.outbound, allow: detail.allow } };
|
|
639
|
-
return detail;
|
|
640
|
-
}
|
|
641
|
-
/**
|
|
642
|
-
* Runs a command and waits for it. A command that fails is still an answer: read
|
|
643
|
-
* exitCode. Every command starts fresh, as root in /root, so a cd or an export does not
|
|
644
|
-
* carry over to the next one. It is not sent again by itself: a command cut off by a
|
|
645
|
-
* proxy or a move may have done part of its work.
|
|
646
|
-
*/
|
|
647
|
-
async exec(command, options = {}) {
|
|
648
|
-
return (await this.http.json(this.command("/exec", command, options))).body;
|
|
649
|
-
}
|
|
650
|
-
/** Runs a command and gives its output as it arrives, then what it exited with. */
|
|
651
|
-
async *execStream(command, options = {}) {
|
|
652
|
-
for await (const { event, data } of this.http.events(this.command("/exec/stream", command, options))) {
|
|
653
|
-
const fields = (data && typeof data === "object" ? data : {});
|
|
654
|
-
if (event === "stdout" || event === "stderr") {
|
|
655
|
-
yield { type: event, data: String(fields.data ?? "") };
|
|
656
|
-
}
|
|
657
|
-
else if (event === "exit") {
|
|
658
|
-
yield { type: "exit", exitCode: Number(fields.exitCode) };
|
|
659
|
-
return;
|
|
660
|
-
}
|
|
661
|
-
else if (event === "error") {
|
|
662
|
-
throw new AgentalError(`The command was cut off: ${typeof data === "string" ? data : JSON.stringify(data)}`);
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
throw new ConnectionError("The stream ended before the command said what it exited with. It may still be running.");
|
|
666
|
-
}
|
|
667
|
-
/**
|
|
668
|
-
* Stops it. The disk is kept, with everything on it, at 5% of the running price where that
|
|
669
|
-
* is charged. What was running is not, unless keepMemory: then start() puts it back, and
|
|
670
|
-
* info.memoryLost says why when it could not.
|
|
671
|
-
*/
|
|
672
|
-
async stop(options = {}) {
|
|
673
|
-
const body = options.keepMemory ? { keepMemory: true } : undefined;
|
|
674
|
-
const answer = await this.http.json({ method: "POST", path: `${this.path}/stop`, ...(body ? { body } : {}), replay: true });
|
|
675
|
-
this.info = answer.body;
|
|
676
|
-
if (answer.status !== 202) {
|
|
677
|
-
return this;
|
|
678
|
-
}
|
|
679
|
-
// Its memory is still being written, and the stop goes on: read it until it has stopped.
|
|
680
|
-
const operationId = answer.headers.get("operation-id");
|
|
681
|
-
const timeoutMs = options.timeoutMs ?? RESTORE_TIMEOUT_MS;
|
|
682
|
-
const deadline = Date.now() + timeoutMs;
|
|
683
|
-
while (this.info.status !== "stopped") {
|
|
684
|
-
if (operationId) {
|
|
685
|
-
const { operation } = (await this.http.json({ method: "GET", path: `/v1/operations/${operationId}`, replay: true })).body;
|
|
686
|
-
if (operation.status === "failed") {
|
|
687
|
-
throw new AgentalError(`Sandbox ${this.id} was not stopped: ${operation.error ?? "the stop failed"}`);
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
if (Date.now() + this.http.pollIntervalMs > deadline) {
|
|
691
|
-
throw new TimeoutError(`Sandbox ${this.id} was still writing its memory after ${timeoutMs} ms. The stop goes on.`);
|
|
692
|
-
}
|
|
693
|
-
await sleep(this.http.pollIntervalMs);
|
|
694
|
-
await this.refresh();
|
|
695
|
-
}
|
|
696
|
-
return this;
|
|
697
|
-
}
|
|
698
|
-
/**
|
|
699
|
-
* Starts it again, and waits until it is running. Nothing that was running in it is
|
|
700
|
-
* started for you. An archived sandbox is brought back first, which takes longer. What it
|
|
701
|
-
* paid in burst fees is in burstFeeMicros afterwards.
|
|
702
|
-
*/
|
|
703
|
-
async start(options = {}) {
|
|
704
|
-
const headers = burstHeaders(options);
|
|
705
|
-
await burstWaited(options, async () => {
|
|
706
|
-
try {
|
|
707
|
-
const answer = await this.http.json({ method: "POST", path: `${this.path}/start`, headers, replay: true });
|
|
708
|
-
this.info = answer.body;
|
|
709
|
-
this.burstFeeMicros = burstFeeOf(answer.headers);
|
|
710
|
-
}
|
|
711
|
-
catch (error) {
|
|
712
|
-
// Whether the start worked is not known yet: the sandbox's status will say.
|
|
713
|
-
if (!(error instanceof ApiError && error.code === "uncertain_operation")) {
|
|
714
|
-
throw error;
|
|
715
|
-
}
|
|
716
|
-
this.burstFeeMicros = null;
|
|
717
|
-
await this.refresh();
|
|
718
|
-
}
|
|
719
|
-
});
|
|
720
|
-
return options.wait === false ? this : await this.waitUntilRunning(options);
|
|
721
|
-
}
|
|
722
|
-
/**
|
|
723
|
-
* Gets a sandbox that is stuck going again: what runs in it is killed, its disk is left as
|
|
724
|
-
* it is, and it ends running. One that is stopped is started, and pays a burst fee as a
|
|
725
|
-
* start does. Not every server can do it: those answer 501 not_implemented.
|
|
726
|
-
*/
|
|
727
|
-
async recover(options = {}) {
|
|
728
|
-
const answer = await this.http.json({ method: "POST", path: `${this.path}/recover`, headers: burstHeaders(options) });
|
|
729
|
-
this.info = answer.body;
|
|
730
|
-
this.burstFeeMicros = burstFeeOf(answer.headers);
|
|
731
|
-
return this;
|
|
732
|
-
}
|
|
733
|
-
/**
|
|
734
|
-
* Reads the sandbox until it is running. It throws for one that is stopped, with why a
|
|
735
|
-
* start of it failed when one did.
|
|
736
|
-
*/
|
|
737
|
-
async waitUntilRunning(options = {}) {
|
|
738
|
-
const timeoutMs = options.timeoutMs ?? RESTORE_TIMEOUT_MS;
|
|
739
|
-
const deadline = Date.now() + timeoutMs;
|
|
740
|
-
for (;;) {
|
|
741
|
-
if (this.status === "running") {
|
|
742
|
-
return this;
|
|
743
|
-
}
|
|
744
|
-
if (this.status !== "creating" && this.status !== "restoring" && this.status !== "migrating") {
|
|
745
|
-
throw new AgentalError(`Sandbox ${this.id} is ${this.status}, not running${this.info.error ? `: ${this.info.error}` : "."}`);
|
|
746
|
-
}
|
|
747
|
-
if (Date.now() + this.http.pollIntervalMs > deadline) {
|
|
748
|
-
throw new TimeoutError(`Sandbox ${this.id} was still ${this.status} after ${timeoutMs} ms.`);
|
|
749
|
-
}
|
|
750
|
-
await sleep(this.http.pollIntervalMs);
|
|
751
|
-
await this.refresh();
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
/**
|
|
755
|
-
* Moves a stopped sandbox's disk to storage, and waits until it is there. It costs the
|
|
756
|
-
* same as a stopped sandbox either way: use it for one that will not be needed for a long while.
|
|
757
|
-
*/
|
|
758
|
-
async archive(options = {}) {
|
|
759
|
-
const answer = await this.http.json({ method: "POST", path: `${this.path}/archive`, replay: true });
|
|
760
|
-
this.info = answer.body;
|
|
761
|
-
if (options.wait === false) {
|
|
762
|
-
return this;
|
|
763
|
-
}
|
|
764
|
-
const operationId = answer.headers.get("operation-id");
|
|
765
|
-
const timeoutMs = options.timeoutMs ?? RESTORE_TIMEOUT_MS;
|
|
766
|
-
const deadline = Date.now() + timeoutMs;
|
|
767
|
-
while (!this.info.archived) {
|
|
768
|
-
if (operationId) {
|
|
769
|
-
const { operation } = (await this.http.json({ method: "GET", path: `/v1/operations/${operationId}`, replay: true })).body;
|
|
770
|
-
if (operation.status === "failed") {
|
|
771
|
-
throw new AgentalError(`Sandbox ${this.id} was not archived: ${operation.error ?? "the archive failed"}`);
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
if (Date.now() + this.http.pollIntervalMs > deadline) {
|
|
775
|
-
throw new TimeoutError(`Sandbox ${this.id} was not archived after ${timeoutMs} ms.`);
|
|
776
|
-
}
|
|
777
|
-
await sleep(this.http.pollIntervalMs);
|
|
778
|
-
await this.refresh();
|
|
779
|
-
}
|
|
780
|
-
return this;
|
|
781
|
-
}
|
|
782
|
-
/**
|
|
783
|
-
* Copies it into a new sandbox, running, with the same disk. From then on the two share
|
|
784
|
-
* nothing. Nothing that was running here is started in the copy.
|
|
785
|
-
*/
|
|
786
|
-
async fork(options = {}) {
|
|
787
|
-
const { info, burstFeeMicros } = await makeSandbox(this.http, `${this.path}/fork`, { name: options.name ?? made("sbx") }, options.idempotencyKey ?? made("sdk"), options.timeoutMs ?? CREATE_TIMEOUT_MS, options);
|
|
788
|
-
return new Sandbox(this.http, info, burstFeeMicros);
|
|
789
|
-
}
|
|
790
|
-
/** Destroys it and its disk for good. */
|
|
791
|
-
async delete() {
|
|
792
|
-
await this.http.send({ method: "DELETE", path: this.path, headers: { "X-Agental-Confirm-Delete": this.id } });
|
|
793
|
-
}
|
|
794
|
-
/** Keeps the sandbox as it is now, to roll back to or to make new sandboxes from. */
|
|
795
|
-
async snapshot(name) {
|
|
796
|
-
return (await this.http.json({ method: "POST", path: `${this.path}/snapshots`, body: { name: name ?? made("snap") } })).body;
|
|
797
|
-
}
|
|
798
|
-
async snapshots() {
|
|
799
|
-
return (await this.http.json({ method: "GET", path: `${this.path}/snapshots`, replay: true })).body.snapshots;
|
|
800
|
-
}
|
|
801
|
-
/** Rolls its disk back to a snapshot of it. What changed since is undone, and snapshots taken since are discarded. */
|
|
802
|
-
async restore(snapshotId) {
|
|
803
|
-
const { body } = await this.http.json({
|
|
804
|
-
method: "POST",
|
|
805
|
-
path: `${this.path}/restore`,
|
|
806
|
-
body: { snapshotId },
|
|
807
|
-
});
|
|
808
|
-
this.info = body.sandbox;
|
|
809
|
-
return { snapshot: body.snapshot, discarded: body.discarded };
|
|
810
|
-
}
|
|
811
|
-
/**
|
|
812
|
-
* Gives a port an address. With public, the default, anyone can open its url without a
|
|
813
|
-
* key while the sandbox is running. What listens must listen on 0.0.0.0.
|
|
814
|
-
*/
|
|
815
|
-
async expose(port, options = {}) {
|
|
816
|
-
const body = { port, title: options.title ?? `port-${port}`, public: options.public ?? true };
|
|
817
|
-
return (await this.http.json({ method: "POST", path: `${this.path}/host`, body })).body;
|
|
818
|
-
}
|
|
819
|
-
async routes() {
|
|
820
|
-
return (await this.http.json({ method: "GET", path: `${this.path}/host`, replay: true })).body.routes;
|
|
821
|
-
}
|
|
822
|
-
/** How long it has run and what that comes to at the list price, and the mail it has had. Without since, its whole life. */
|
|
823
|
-
async usage(window = {}) {
|
|
824
|
-
const query = { since: iso(window.since), until: iso(window.until) };
|
|
825
|
-
return (await this.http.json({ method: "GET", path: `${this.path}/usage`, query, replay: true })).body;
|
|
826
|
-
}
|
|
827
|
-
/**
|
|
828
|
-
* What it read between two moments: its CPU, memory, disk and network, oldest first.
|
|
829
|
-
* Without since, the last hour. stepSeconds is the seconds between two points, 60 at the
|
|
830
|
-
* least. There is no point for a moment it was stopped at. complete is false when a
|
|
831
|
-
* server did not answer, and readings may then be missing. Only where policy() says metrics.
|
|
832
|
-
*/
|
|
833
|
-
async metrics(window = {}) {
|
|
834
|
-
const query = { since: iso(window.since), until: iso(window.until), stepSeconds: window.stepSeconds?.toString() };
|
|
835
|
-
return (await this.http.json({ method: "GET", path: `${this.path}/metrics`, query, replay: true })).body;
|
|
836
|
-
}
|
|
837
|
-
command(suffix, command, options) {
|
|
838
|
-
return {
|
|
839
|
-
method: "POST",
|
|
840
|
-
path: `${this.path}${suffix}`,
|
|
841
|
-
body: { command: argv(command), ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }) },
|
|
842
|
-
...(options.signal ? { signal: options.signal } : {}),
|
|
843
|
-
};
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
|
-
export { CREATE_TIMEOUT_MS, iso, made };
|