@prized/sdk 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/README.md +625 -2
- package/dist/cjs/commands.d.ts +168 -0
- package/dist/cjs/commands.js +567 -0
- package/dist/cjs/errors.d.ts +73 -0
- package/dist/cjs/errors.js +258 -0
- package/dist/cjs/files.d.ts +86 -0
- package/dist/cjs/files.js +366 -0
- package/dist/cjs/http.d.ts +210 -0
- package/dist/cjs/http.js +570 -0
- package/dist/cjs/index.d.ts +12 -0
- package/dist/cjs/index.js +22 -0
- package/dist/cjs/outcome.d.ts +75 -0
- package/dist/cjs/outcome.js +167 -0
- package/dist/cjs/output.d.ts +59 -0
- package/dist/cjs/output.js +288 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/sandbox.d.ts +218 -0
- package/dist/cjs/sandbox.js +810 -0
- package/dist/cjs/shell.d.ts +152 -0
- package/dist/cjs/shell.js +393 -0
- package/dist/cjs/toolbox.d.ts +48 -0
- package/dist/cjs/toolbox.js +86 -0
- package/dist/cjs/types.d.ts +99 -0
- package/dist/cjs/types.js +102 -0
- package/dist/cjs/version.d.ts +2 -0
- package/dist/cjs/version.js +5 -0
- package/dist/esm/commands.d.ts +168 -0
- package/dist/esm/commands.js +560 -0
- package/dist/esm/errors.d.ts +73 -0
- package/dist/esm/errors.js +250 -0
- package/dist/esm/files.d.ts +86 -0
- package/dist/esm/files.js +360 -0
- package/dist/esm/http.d.ts +210 -0
- package/dist/esm/http.js +553 -0
- package/dist/esm/index.d.ts +12 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/outcome.d.ts +75 -0
- package/dist/esm/outcome.js +152 -0
- package/dist/esm/output.d.ts +59 -0
- package/dist/esm/output.js +280 -0
- package/dist/esm/sandbox.d.ts +218 -0
- package/dist/esm/sandbox.js +806 -0
- package/dist/esm/shell.d.ts +152 -0
- package/dist/esm/shell.js +379 -0
- package/dist/esm/toolbox.d.ts +48 -0
- package/dist/esm/toolbox.js +81 -0
- package/dist/esm/types.d.ts +99 -0
- package/dist/esm/types.js +95 -0
- package/dist/esm/version.d.ts +2 -0
- package/dist/esm/version.js +2 -0
- package/package.json +53 -4
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Files = exports.EXIT_TARGET_NOT_REGULAR = exports.EXIT_TARGET_IS_LINK = exports.FILE_TIMEOUT_MS = void 0;
|
|
4
|
+
exports.stagingFor = stagingFor;
|
|
5
|
+
exports.publishScript = publishScript;
|
|
6
|
+
// sandbox.files: read, write, list, remove, upload, download.
|
|
7
|
+
const errors_js_1 = require("./errors.js");
|
|
8
|
+
const http_js_1 = require("./http.js");
|
|
9
|
+
const outcome_js_1 = require("./outcome.js");
|
|
10
|
+
const shell_js_1 = require("./shell.js");
|
|
11
|
+
const toolbox_js_1 = require("./toolbox.js");
|
|
12
|
+
/** The sandbox's file transfers take at most this long, retries included. */
|
|
13
|
+
exports.FILE_TIMEOUT_MS = 10 * 60_000;
|
|
14
|
+
function byteSize(data) {
|
|
15
|
+
if (typeof data === 'string')
|
|
16
|
+
return new TextEncoder().encode(data).length;
|
|
17
|
+
if (data instanceof Uint8Array)
|
|
18
|
+
return data.byteLength;
|
|
19
|
+
if (data instanceof ArrayBuffer)
|
|
20
|
+
return data.byteLength;
|
|
21
|
+
return data.size;
|
|
22
|
+
}
|
|
23
|
+
async function nodeFs() {
|
|
24
|
+
try {
|
|
25
|
+
return (await Promise.resolve().then(() => require('node:fs/promises')));
|
|
26
|
+
}
|
|
27
|
+
catch (cause) {
|
|
28
|
+
throw new errors_js_1.PrizedError('invalid_argument', 'upload() and download() need a local file system (Node, Bun or Deno); use write() and readBytes() instead.', { cause });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// A write goes up under a fresh name beside its target, and one command then
|
|
32
|
+
// renames it over the target, giving it the replaced file's permission bits
|
|
33
|
+
// first: uploaded straight over a file, the bytes would land 0600 on some
|
|
34
|
+
// sandbox images, and a 0755 script would stop running (exit 126). A new file
|
|
35
|
+
// keeps the mode the files API gave the staged one, its own rule for new
|
|
36
|
+
// files, so a write follows the same rules on every image. The Python SDK
|
|
37
|
+
// renders the same script (_core.py publish_command; sdk/file-scripts).
|
|
38
|
+
const HOME_DIR = '/home/prized';
|
|
39
|
+
/** A staged write's name, beside its target. */
|
|
40
|
+
const STAGING_PREFIX = '.prized-write-';
|
|
41
|
+
/** The publish script's own exits (the Python SDK's too). */
|
|
42
|
+
exports.EXIT_TARGET_IS_LINK = 47;
|
|
43
|
+
exports.EXIT_TARGET_NOT_REGULAR = 48;
|
|
44
|
+
/** How long removing a staged file that was never put in place may take. */
|
|
45
|
+
const DISCARD_MS = 10_000;
|
|
46
|
+
/** A write's own commands: a rename, a chmod or an rm. */
|
|
47
|
+
const WRITE_COMMAND_MS = 60_000;
|
|
48
|
+
/** For the staged upload: the target is changed only by the command that follows it. */
|
|
49
|
+
function stagedEffect(path) {
|
|
50
|
+
return {
|
|
51
|
+
maybe: `${path} was not changed (its new bytes may have reached the sandbox, but were not put in place)`,
|
|
52
|
+
did: `${path} was not changed yet`,
|
|
53
|
+
check: 'It was not sent again: write it again.',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** For the command that puts the staged upload in place: a 2xx proves it ran, not how it ended. */
|
|
57
|
+
function publishEffect(path) {
|
|
58
|
+
return { ...(0, outcome_js_1.writeEffect)(path), did: `${path} may or may not have been written: the command that puts it in place ran` };
|
|
59
|
+
}
|
|
60
|
+
function randomHex(bytes) {
|
|
61
|
+
const out = new Uint8Array(bytes);
|
|
62
|
+
const webcrypto = globalThis.crypto;
|
|
63
|
+
if (webcrypto?.getRandomValues) {
|
|
64
|
+
webcrypto.getRandomValues(out);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
for (let i = 0; i < bytes; i += 1)
|
|
68
|
+
out[i] = Math.floor(Math.random() * 256);
|
|
69
|
+
}
|
|
70
|
+
return Array.from(out, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Where a write to `target` is staged: `clean` is the target as the files
|
|
74
|
+
* API reads it (`.`, `//` and a trailing `/` gone), and `staging` a fresh
|
|
75
|
+
* name in the same directory. Null when the files API has to judge the path
|
|
76
|
+
* itself (outside /home/prized, `..`, NUL): it is then uploaded as it is, and
|
|
77
|
+
* refused as it always was.
|
|
78
|
+
*/
|
|
79
|
+
function stagingFor(target, suffix = randomHex(16)) {
|
|
80
|
+
if (!target.startsWith('/') || target.includes('\0'))
|
|
81
|
+
return null;
|
|
82
|
+
const parts = target.split('/').filter((part) => part !== '' && part !== '.');
|
|
83
|
+
if (parts.includes('..'))
|
|
84
|
+
return null;
|
|
85
|
+
const clean = `/${parts.join('/')}`;
|
|
86
|
+
if (!clean.startsWith(`${HOME_DIR}/`))
|
|
87
|
+
return null;
|
|
88
|
+
return { clean, staging: `${clean.slice(0, clean.lastIndexOf('/') + 1)}${STAGING_PREFIX}${suffix}` };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The command that puts the staged upload `staging` in place at `target`
|
|
92
|
+
* (both absolute, in one directory) with one rename. A regular file there
|
|
93
|
+
* lends its permission bits (setuid, setgid and sticky are not carried) and,
|
|
94
|
+
* where the sandbox user may, its owner and group; `mode`, when given, sets
|
|
95
|
+
* the bits instead. With nothing there the staged file keeps the files API's
|
|
96
|
+
* mode for a new file. A link there exits 47 and a directory or other
|
|
97
|
+
* non-regular file exits 48, changing nothing. The command removes the
|
|
98
|
+
* staged file as it exits (unless it is killed).
|
|
99
|
+
*/
|
|
100
|
+
function publishScript(staging, target, mode) {
|
|
101
|
+
const chmod = (bits) => `chmod ${bits} -- "$src" || exit`;
|
|
102
|
+
return [
|
|
103
|
+
`src=${(0, shell_js_1.shellQuote)(staging)}`,
|
|
104
|
+
`dst=${(0, shell_js_1.shellQuote)(target)}`,
|
|
105
|
+
`trap 'rm -f -- "$src"' EXIT`,
|
|
106
|
+
// One lstat: the type, the bits and the owner come from the same file.
|
|
107
|
+
// `+`: the ids as numbers, never looked up as names.
|
|
108
|
+
`if s=$(stat -c '%f +%u:+%g' -- "$dst" 2> /dev/null); then`,
|
|
109
|
+
` t=$((0x\${s%% *} & 0170000))`,
|
|
110
|
+
` [ "$t" -eq $((0120000)) ] && exit ${exports.EXIT_TARGET_IS_LINK}`,
|
|
111
|
+
` [ "$t" -eq $((0100000)) ] || exit ${exports.EXIT_TARGET_NOT_REGULAR}`,
|
|
112
|
+
` chown -- "\${s#* }" "$src" 2> /dev/null || chgrp -- "\${s##*:}" "$src" 2> /dev/null`,
|
|
113
|
+
...(mode === null ? [` ${chmod('"$(printf %o $((0x${s%% *} & 0777)))"')}`, 'fi'] : ['fi', chmod(mode.toString(8))]),
|
|
114
|
+
`mv -fT -- "$src" "$dst" || exit`,
|
|
115
|
+
// The rename is on disk before the answer, as an upload's is.
|
|
116
|
+
`sync -- "\${dst%/*}" 2> /dev/null`,
|
|
117
|
+
'exit 0',
|
|
118
|
+
].join('\n');
|
|
119
|
+
}
|
|
120
|
+
/** A command's stderr in one line, for an error message. */
|
|
121
|
+
function commandDetail(result) {
|
|
122
|
+
return result.stderr.trim().replace(/\s+/g, ' ').slice(0, 300) || `exit ${result.exitCode}`;
|
|
123
|
+
}
|
|
124
|
+
/** An upload or command answered 2xx whose body could not be read: it took effect. */
|
|
125
|
+
function landed(error) {
|
|
126
|
+
return error instanceof errors_js_1.PrizedError && error.status !== null && error.status >= 200 && error.status < 300;
|
|
127
|
+
}
|
|
128
|
+
function checkMode(mode, sandboxId) {
|
|
129
|
+
if (mode === undefined)
|
|
130
|
+
return null;
|
|
131
|
+
if (typeof mode !== 'number' || !Number.isInteger(mode) || mode < 0 || mode > 0o777) {
|
|
132
|
+
throw new errors_js_1.PrizedError('invalid_argument', `mode must be permission bits from 0 to 0o777 (0o755 for a script), got ${String(mode)}.`, { sandboxId });
|
|
133
|
+
}
|
|
134
|
+
return mode;
|
|
135
|
+
}
|
|
136
|
+
function parentDir(path) {
|
|
137
|
+
const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
|
|
138
|
+
return cut > 0 ? path.slice(0, cut) : null;
|
|
139
|
+
}
|
|
140
|
+
/** File operations on one sandbox. Paths are absolute, or relative to /home/prized/work. */
|
|
141
|
+
class Files {
|
|
142
|
+
toolbox;
|
|
143
|
+
/** @internal */
|
|
144
|
+
constructor(toolbox) {
|
|
145
|
+
this.toolbox = toolbox;
|
|
146
|
+
}
|
|
147
|
+
url(op, path) {
|
|
148
|
+
return `${this.toolbox.base()}/files/${op}?path=${encodeURIComponent((0, shell_js_1.sandboxPath)(path))}`;
|
|
149
|
+
}
|
|
150
|
+
/** A file's contents as UTF-8 text. */
|
|
151
|
+
async read(path) {
|
|
152
|
+
return new TextDecoder('utf-8').decode(await this.readBytes(path));
|
|
153
|
+
}
|
|
154
|
+
/** A file's contents as bytes (at most 100 MiB). */
|
|
155
|
+
async readBytes(path) {
|
|
156
|
+
const result = await (0, http_js_1.request)(this.toolbox.config, {
|
|
157
|
+
method: 'GET',
|
|
158
|
+
url: this.url('download', path),
|
|
159
|
+
retry: 'safe',
|
|
160
|
+
read: 'bytes',
|
|
161
|
+
timeoutMs: exports.FILE_TIMEOUT_MS,
|
|
162
|
+
deadline: this.toolbox.config.now() + exports.FILE_TIMEOUT_MS,
|
|
163
|
+
sandboxId: this.toolbox.sandboxId,
|
|
164
|
+
});
|
|
165
|
+
return result.bytes ?? new Uint8Array(0);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Write a file (at most 100 MiB), replacing it whole if it exists. Missing
|
|
169
|
+
* parent directories are created. The path must be inside /home/prized.
|
|
170
|
+
* A file that is replaced keeps its mode, so a script made executable
|
|
171
|
+
* stays executable; a new file gets the sandbox's default (0600 in
|
|
172
|
+
* prized-base-v1 and prized-agent-v1). `mode` sets the permission bits either way.
|
|
173
|
+
* Sent again only if it provably never arrived: a write whose answer was
|
|
174
|
+
* lost throws, since it may have landed and the sandbox written the file
|
|
175
|
+
* since (read it back, or write again, as suits the file).
|
|
176
|
+
*/
|
|
177
|
+
async write(path, data, options = {}) {
|
|
178
|
+
const sandboxId = this.toolbox.sandboxId;
|
|
179
|
+
const size = byteSize(data);
|
|
180
|
+
if (size > toolbox_js_1.MAX_FILE_BYTES) {
|
|
181
|
+
throw new errors_js_1.PrizedError('file_too_large', null, { sandboxId });
|
|
182
|
+
}
|
|
183
|
+
const mode = checkMode(options.mode, sandboxId);
|
|
184
|
+
const target = (0, shell_js_1.sandboxPath)(path);
|
|
185
|
+
const plan = stagingFor(target);
|
|
186
|
+
if (plan === null)
|
|
187
|
+
return this.send(target, data, size, target, (0, outcome_js_1.writeEffect)(target));
|
|
188
|
+
const { clean, staging } = plan;
|
|
189
|
+
try {
|
|
190
|
+
await this.send(staging, data, size, clean, stagedEffect(clean));
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
// A 2xx whose body could not be read: the staged bytes are all there.
|
|
194
|
+
if (!landed(error)) {
|
|
195
|
+
if ((0, outcome_js_1.mayHaveActed)(error))
|
|
196
|
+
await this.discard(staging);
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
let result;
|
|
201
|
+
try {
|
|
202
|
+
// Like any write: a lost answer is not sent again (it may have renamed).
|
|
203
|
+
result = await this.run(publishScript(staging, clean, mode), { retry: 'exec', effect: publishEffect(clean) });
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
// Unless it ran (a 2xx), the staged file may still be there; once it
|
|
207
|
+
// ran, the command removed it itself.
|
|
208
|
+
if (!landed(error))
|
|
209
|
+
await this.discard(staging);
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
if (result.timedOut) {
|
|
213
|
+
await this.discard(staging); // killed, perhaps before it could remove it
|
|
214
|
+
throw (0, outcome_js_1.withOutcome)(new errors_js_1.PrizedError('command_timeout', `Putting ${clean} in place timed out.`, { sandboxId }), (0, outcome_js_1.writeEffect)(clean), 'maybe');
|
|
215
|
+
}
|
|
216
|
+
if (result.exitCode === 0)
|
|
217
|
+
return { path: clean, size };
|
|
218
|
+
if (result.exitCode === exports.EXIT_TARGET_IS_LINK) {
|
|
219
|
+
// A link: the files API follows it or refuses it, as for any upload.
|
|
220
|
+
const info = await this.send(clean, data, size, clean, (0, outcome_js_1.writeEffect)(clean));
|
|
221
|
+
if (mode !== null)
|
|
222
|
+
await this.chmod(clean, mode);
|
|
223
|
+
return info;
|
|
224
|
+
}
|
|
225
|
+
if (result.exitCode === exports.EXIT_TARGET_NOT_REGULAR) {
|
|
226
|
+
throw new errors_js_1.PrizedError('unsupported_file_type', `${clean} is a directory or another file that is not a regular file; only a regular file can be written.`, { sandboxId });
|
|
227
|
+
}
|
|
228
|
+
const denied = /Permission denied|Operation not permitted/.test(result.stderr);
|
|
229
|
+
throw new errors_js_1.PrizedError(denied ? 'permission_denied' : 'command_failed', `Could not write ${clean}: ${commandDetail(result)}`, { sandboxId });
|
|
230
|
+
}
|
|
231
|
+
/** One upload to `path`, as the files API takes it; `target` is the file the caller asked for, and `effect` what the upload does to it. */
|
|
232
|
+
async send(path, data, size, target, effect) {
|
|
233
|
+
const result = await (0, http_js_1.request)(this.toolbox.config, {
|
|
234
|
+
method: 'POST',
|
|
235
|
+
url: this.url('upload', path),
|
|
236
|
+
body: data,
|
|
237
|
+
contentType: 'application/octet-stream',
|
|
238
|
+
// Like a command: a resend after the answer was lost could overwrite
|
|
239
|
+
// what the sandbox wrote to the path since.
|
|
240
|
+
retry: 'exec',
|
|
241
|
+
effect,
|
|
242
|
+
timeoutMs: exports.FILE_TIMEOUT_MS,
|
|
243
|
+
deadline: this.toolbox.config.now() + exports.FILE_TIMEOUT_MS,
|
|
244
|
+
sandboxId: this.toolbox.sandboxId,
|
|
245
|
+
});
|
|
246
|
+
const doc = (result.json ?? {});
|
|
247
|
+
return {
|
|
248
|
+
path: path === target && typeof doc.path === 'string' ? doc.path : target,
|
|
249
|
+
size: typeof doc.size === 'number' ? doc.size : size,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Remove a staged upload that was not put in place (best effort: the
|
|
254
|
+
* write's own error matters more). Sent again like a read, unlike other
|
|
255
|
+
* removes: the name is this call's own, so a second rm finds nothing
|
|
256
|
+
* anyone else made.
|
|
257
|
+
*/
|
|
258
|
+
async discard(staging) {
|
|
259
|
+
try {
|
|
260
|
+
await this.run((0, shell_js_1.removeScript)(staging, false), { retry: 'safe', deadline: this.toolbox.config.now() + DISCARD_MS });
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
// The write's error is the one to report.
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
async chmod(path, mode) {
|
|
267
|
+
const result = await this.run(`chmod ${mode.toString(8)} -- ${(0, shell_js_1.shellQuote)(path)}`, { retry: 'safe' });
|
|
268
|
+
if (result.exitCode !== 0) {
|
|
269
|
+
throw new errors_js_1.PrizedError('command_failed', `${path} was written, but its mode could not be set: ${commandDetail(result)}`, {
|
|
270
|
+
sandboxId: this.toolbox.sandboxId,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/** One of a write's own commands, run in the home, which is always there (the workspace may not be). */
|
|
275
|
+
run(script, options) {
|
|
276
|
+
return (0, toolbox_js_1.rawExec)(this.toolbox, { command: script, cwd: HOME_DIR, timeoutMs: WRITE_COMMAND_MS }, options);
|
|
277
|
+
}
|
|
278
|
+
/** A directory's entries, sorted by name (default: /home/prized/work). */
|
|
279
|
+
async list(path = shell_js_1.WORKSPACE_DIR) {
|
|
280
|
+
const dir = (0, shell_js_1.sandboxPath)(path).replace(/(.)\/+$/, '$1');
|
|
281
|
+
const result = await (0, toolbox_js_1.helperExec)(this.toolbox, (0, shell_js_1.listScript)(dir));
|
|
282
|
+
if (result.exitCode === 20) {
|
|
283
|
+
throw new errors_js_1.PrizedError('not_directory', `${dir} is not a directory.`, { sandboxId: this.toolbox.sandboxId });
|
|
284
|
+
}
|
|
285
|
+
if (result.exitCode === 21) {
|
|
286
|
+
throw new errors_js_1.PrizedError('not_found', `${dir} does not exist.`, { sandboxId: this.toolbox.sandboxId });
|
|
287
|
+
}
|
|
288
|
+
if (result.exitCode !== 0) {
|
|
289
|
+
throw new errors_js_1.PrizedError('command_failed', result.stderr.trim() || `list exited ${result.exitCode}`, {
|
|
290
|
+
sandboxId: this.toolbox.sandboxId,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
if (result.truncated) {
|
|
294
|
+
// Past the per-call output cap the listing is incomplete: say so rather
|
|
295
|
+
// than return a short array that looks whole.
|
|
296
|
+
throw new errors_js_1.PrizedError('command_failed', `${dir} has too many entries to list in one call; list a narrower directory.`, {
|
|
297
|
+
sandboxId: this.toolbox.sandboxId,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
const entries = [];
|
|
301
|
+
for (const rec of result.stdout.split('\0')) {
|
|
302
|
+
if (rec === '')
|
|
303
|
+
continue;
|
|
304
|
+
const parts = rec.split('\t');
|
|
305
|
+
if (parts.length < 5)
|
|
306
|
+
continue;
|
|
307
|
+
const [type, size, mode, mtime] = parts;
|
|
308
|
+
const name = parts.slice(4).join('\t');
|
|
309
|
+
const seconds = Number(mtime);
|
|
310
|
+
entries.push({
|
|
311
|
+
name,
|
|
312
|
+
path: dir === '/' ? `/${name}` : `${dir}/${name}`,
|
|
313
|
+
size: Number(size) || 0,
|
|
314
|
+
isDir: type === 'd',
|
|
315
|
+
isSymlink: type === 'l',
|
|
316
|
+
mode,
|
|
317
|
+
modified: Number.isFinite(seconds) ? new Date(Math.round(seconds * 1000)) : null,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
321
|
+
return entries;
|
|
322
|
+
}
|
|
323
|
+
/** Whether a path exists (a file, a directory, or a link, even a dangling one). */
|
|
324
|
+
async exists(path) {
|
|
325
|
+
const result = await (0, toolbox_js_1.helperExec)(this.toolbox, (0, shell_js_1.existsScript)((0, shell_js_1.sandboxPath)(path)));
|
|
326
|
+
if (result.exitCode === 0)
|
|
327
|
+
return true;
|
|
328
|
+
if (result.exitCode === 1)
|
|
329
|
+
return false;
|
|
330
|
+
throw new errors_js_1.PrizedError('command_failed', result.stderr.trim() || `exists exited ${result.exitCode}`, {
|
|
331
|
+
sandboxId: this.toolbox.sandboxId,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
/** Remove a file, or a directory tree with `recursive: true`. A missing path is not an error. */
|
|
335
|
+
async remove(path, options = {}) {
|
|
336
|
+
const target = (0, shell_js_1.sandboxPath)(path);
|
|
337
|
+
// Command retry rules: a lost answer is not sent again, or a path the
|
|
338
|
+
// sandbox recreated in the meantime would be deleted too.
|
|
339
|
+
const result = await (0, toolbox_js_1.helperExec)(this.toolbox, (0, shell_js_1.removeScript)(target, options.recursive === true), {
|
|
340
|
+
retry: 'exec',
|
|
341
|
+
effect: (0, outcome_js_1.removeEffect)(target),
|
|
342
|
+
});
|
|
343
|
+
if (result.exitCode !== 0) {
|
|
344
|
+
throw new errors_js_1.PrizedError('command_failed', result.stderr.trim() || `rm exited ${result.exitCode}`, {
|
|
345
|
+
sandboxId: this.toolbox.sandboxId,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/** Copy a local file into the sandbox (Node, Bun, Deno), as write() does. */
|
|
350
|
+
async upload(localPath, remotePath, options = {}) {
|
|
351
|
+
const fs = await nodeFs();
|
|
352
|
+
const data = await fs.readFile(localPath);
|
|
353
|
+
return this.write(remotePath, new Uint8Array(data.buffer, data.byteOffset, data.byteLength), options);
|
|
354
|
+
}
|
|
355
|
+
/** Copy a file out of the sandbox to a local path (Node, Bun, Deno). Returns the byte count. */
|
|
356
|
+
async download(remotePath, localPath) {
|
|
357
|
+
const fs = await nodeFs();
|
|
358
|
+
const bytes = await this.readBytes(remotePath);
|
|
359
|
+
const parent = parentDir(localPath);
|
|
360
|
+
if (parent !== null)
|
|
361
|
+
await fs.mkdir(parent, { recursive: true });
|
|
362
|
+
await fs.writeFile(localPath, bytes);
|
|
363
|
+
return bytes.byteLength;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
exports.Files = Files;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { PrizedError } from './errors.js';
|
|
2
|
+
import { type Effect, type Outcome } from './outcome.js';
|
|
3
|
+
export declare const DEFAULT_BASE_URL = "https://api.prized.dev";
|
|
4
|
+
export declare const DEFAULT_EDGE_URL = "https://term.prized.dev";
|
|
5
|
+
/** Options every entry point accepts. All optional. */
|
|
6
|
+
export interface ClientOptions {
|
|
7
|
+
/** Your API key (`dcp_…`). Default: the `PRIZED_API_KEY` environment variable. */
|
|
8
|
+
apiKey?: string;
|
|
9
|
+
/** The API origin. Default: `https://api.prized.dev`. */
|
|
10
|
+
baseUrl?: string;
|
|
11
|
+
/**
|
|
12
|
+
* The origin that serves exec and files. Default: the `toolboxBase` the API
|
|
13
|
+
* returns for each sandbox (today `https://term.prized.dev`).
|
|
14
|
+
*/
|
|
15
|
+
edgeUrl?: string;
|
|
16
|
+
/** A fetch implementation. Default: the global `fetch`. */
|
|
17
|
+
fetch?: typeof fetch;
|
|
18
|
+
/** Retries after the first attempt for retryable failures. Default 3. */
|
|
19
|
+
maxRetries?: number;
|
|
20
|
+
/** Per-request timeout for API calls, in ms. Default 60 000. */
|
|
21
|
+
requestTimeoutMs?: number;
|
|
22
|
+
/** The longest the SDK's own polling goes between checks, in ms (the first come sooner); excludes
|
|
23
|
+
* the request itself and the API's hold of a create, pause or resume. Default 500. */
|
|
24
|
+
pollIntervalMs?: number;
|
|
25
|
+
/**
|
|
26
|
+
* How long a streamed or long `exec` keeps polling while its polls fail
|
|
27
|
+
* (no answer, a 5xx, a rate limit), in ms, before it gives up and stops
|
|
28
|
+
* the command. Default 120 000. `0` gives up once one poll's retries run
|
|
29
|
+
* out. `exec` takes it per call too.
|
|
30
|
+
*/
|
|
31
|
+
pollOutageMs?: number;
|
|
32
|
+
/** Sleep seam (tests inject one that never really waits). */
|
|
33
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
34
|
+
/** Clock seam: epoch milliseconds. Default `Date.now`. */
|
|
35
|
+
now?: () => number;
|
|
36
|
+
/** Jitter seam: a number in [0, 1). Default `Math.random`. */
|
|
37
|
+
random?: () => number;
|
|
38
|
+
}
|
|
39
|
+
export interface Config {
|
|
40
|
+
apiKey: string;
|
|
41
|
+
baseUrl: string;
|
|
42
|
+
edgeUrl: string | null;
|
|
43
|
+
fetch: typeof fetch;
|
|
44
|
+
maxRetries: number;
|
|
45
|
+
requestTimeoutMs: number;
|
|
46
|
+
pollIntervalMs: number;
|
|
47
|
+
pollOutageMs: number;
|
|
48
|
+
sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
49
|
+
now: () => number;
|
|
50
|
+
random: () => number;
|
|
51
|
+
}
|
|
52
|
+
export declare function abortableSleep(ms: number, signal?: AbortSignal): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* How long a streamed or long exec keeps polling while its polls fail
|
|
55
|
+
* before it stops the command (pollOutageMs): long enough to ride out a
|
|
56
|
+
* dropped connection or an edge that restarts. The Python SDK's
|
|
57
|
+
* DEFAULT_POLL_OUTAGE.
|
|
58
|
+
*/
|
|
59
|
+
export declare const DEFAULT_POLL_OUTAGE_MS = 120000;
|
|
60
|
+
/** A pollOutageMs given (a number of ms ≥ 0), or undefined. */
|
|
61
|
+
export declare function validOutage(value: number | undefined): number | undefined;
|
|
62
|
+
export declare function resolveConfig(options?: ClientOptions): Config;
|
|
63
|
+
/**
|
|
64
|
+
* Where exec and files go for one sandbox. An explicit edgeUrl wins; else the
|
|
65
|
+
* API's own `toolboxBase` when it has the expected shape (https, or plain
|
|
66
|
+
* http on loopback for local development); else the default edge.
|
|
67
|
+
*/
|
|
68
|
+
export declare function toolboxBaseFor(config: Config, sandboxId: string, toolboxBase: string | null): string;
|
|
69
|
+
/**
|
|
70
|
+
* The edge this config's sandboxes answer on, before the API has named one:
|
|
71
|
+
* an explicit edgeUrl, else the default edge for the default API. null for
|
|
72
|
+
* any other API, whose edge is known only from its answer.
|
|
73
|
+
*/
|
|
74
|
+
export declare function expectedEdge(config: Config): string | null;
|
|
75
|
+
/**
|
|
76
|
+
* Open a connection to the edge before the first command needs it: one
|
|
77
|
+
* `GET /healthz` without the API key (the route needs none), its body read
|
|
78
|
+
* so the connection goes back to the pool. Sent beside a held create, it
|
|
79
|
+
* takes DNS, TCP and TLS off the first command after the sandbox runs. Best
|
|
80
|
+
* effort: never throws, and an edge it cannot reach costs only itself.
|
|
81
|
+
*/
|
|
82
|
+
export declare function warmEdge(config: Config): Promise<void>;
|
|
83
|
+
/**
|
|
84
|
+
* Which failures a request may retry (sdk/retry-spec.json, which the
|
|
85
|
+
* Python SDK shares, lists which calls take which):
|
|
86
|
+
* - `safe`: the request can be repeated without a second effect (reads,
|
|
87
|
+
* state verbs, creates and forks that carry an Idempotency-Key).
|
|
88
|
+
* Retries network failures, 429/502/503/504, and everything `exec`
|
|
89
|
+
* retries (the edge's 500 control_plane_unavailable among them). A GET
|
|
90
|
+
* retries any 500 too (the API's `internal`).
|
|
91
|
+
* - `exec`: a command, an upload or a remove: what the sandbox does
|
|
92
|
+
* meanwhile could be undone by a second copy. Retried only when it
|
|
93
|
+
* provably never arrived: a connection that never opened, or an answer
|
|
94
|
+
* saying so (the edge's rate limit, its lookup failing). A lost answer
|
|
95
|
+
* is never retried: it may already have run.
|
|
96
|
+
*/
|
|
97
|
+
export type RetryMode = 'safe' | 'exec';
|
|
98
|
+
/**
|
|
99
|
+
* Whether a fetch that threw never sent its request: the connection was
|
|
100
|
+
* refused, the name did not resolve, or connecting timed out. Anything else
|
|
101
|
+
* (a reset, a timeout once connected, a runtime that names no cause) may
|
|
102
|
+
* have been delivered.
|
|
103
|
+
*/
|
|
104
|
+
export declare function neverSent(error: unknown, depth?: number): boolean;
|
|
105
|
+
export declare function retryable(error: PrizedError, mode: RetryMode, method: RequestSpec['method']): boolean;
|
|
106
|
+
export interface RequestSpec {
|
|
107
|
+
method: 'GET' | 'POST' | 'DELETE';
|
|
108
|
+
/** The URL, or one built per attempt from the ms left before `deadline`
|
|
109
|
+
* (Infinity without one): a held request asks only for what is left. */
|
|
110
|
+
url: string | ((leftMs: number) => string);
|
|
111
|
+
json?: unknown;
|
|
112
|
+
body?: string | Uint8Array | ArrayBuffer | Blob;
|
|
113
|
+
contentType?: string;
|
|
114
|
+
headers?: Record<string, string>;
|
|
115
|
+
retry: RetryMode;
|
|
116
|
+
timeoutMs?: number;
|
|
117
|
+
/**
|
|
118
|
+
* Epoch ms (config.now()) bounding the whole call: each attempt's timeout
|
|
119
|
+
* is cut to what is left, and no retry starts past it.
|
|
120
|
+
*/
|
|
121
|
+
deadline?: number | undefined;
|
|
122
|
+
signal?: AbortSignal | undefined;
|
|
123
|
+
sandboxId?: string | null;
|
|
124
|
+
/** How to read a 2xx body. Default `json`. */
|
|
125
|
+
read?: 'json' | 'bytes' | 'none';
|
|
126
|
+
/**
|
|
127
|
+
* Told of every attempt that failed (no answer, an error answer, or a 2xx
|
|
128
|
+
* that could not be read), before it is retried or thrown. A create or
|
|
129
|
+
* fork judges by them all whether it may have made sandboxes: the error
|
|
130
|
+
* thrown is only the last attempt's.
|
|
131
|
+
*/
|
|
132
|
+
onAttemptFailed?: (error: PrizedError) => void;
|
|
133
|
+
/**
|
|
134
|
+
* What the request does in the sandbox (`exec`: a command unless given), so
|
|
135
|
+
* the error thrown once it may have arrived says whether that happened
|
|
136
|
+
* (outcome.ts). Given for a `safe` request (the keyed background start),
|
|
137
|
+
* its error says so too.
|
|
138
|
+
*/
|
|
139
|
+
effect?: Effect;
|
|
140
|
+
}
|
|
141
|
+
export interface HttpResult {
|
|
142
|
+
status: number;
|
|
143
|
+
json: unknown;
|
|
144
|
+
bytes: Uint8Array | null;
|
|
145
|
+
}
|
|
146
|
+
/** The first retry's backoff; each later one doubles, up to MAX_BACKOFF_MS (jittered down to half). */
|
|
147
|
+
export declare const BACKOFF_FIRST_MS = 500;
|
|
148
|
+
export declare const MAX_BACKOFF_MS = 8000;
|
|
149
|
+
/**
|
|
150
|
+
* The longest Retry-After the SDK waits for, the Python SDK's too: twice the
|
|
151
|
+
* longest the API asks for on a route the SDK calls (30 s, with 503
|
|
152
|
+
* sandbox_capacity; the edge asks for 1 s). A longer one is still on the
|
|
153
|
+
* error (`retryAfter`) if the retries run out.
|
|
154
|
+
*/
|
|
155
|
+
export declare const MAX_RETRY_AFTER_MS = 60000;
|
|
156
|
+
/**
|
|
157
|
+
* The bound on a call the caller gives no timeout (connect, list, templates,
|
|
158
|
+
* refresh, keepAlive), retries included: twice requestTimeoutMs, room for one
|
|
159
|
+
* attempt that runs out and the retries after it.
|
|
160
|
+
*/
|
|
161
|
+
export declare function callDeadline(config: Config): number;
|
|
162
|
+
/**
|
|
163
|
+
* Whether request() ended because the call's deadline came first: no time
|
|
164
|
+
* was left, or the retry it was due could not start in time. A wait relabels
|
|
165
|
+
* only these as its own timeout; an attempt that got no answer while time
|
|
166
|
+
* was left is the API not answering, and is thrown as it is.
|
|
167
|
+
*/
|
|
168
|
+
export declare function cutByDeadline(error: unknown): error is PrizedError;
|
|
169
|
+
/** The least room a wait leaves before its deadline for its last poll to be answered. */
|
|
170
|
+
export declare const LAST_LOOK_MS = 100;
|
|
171
|
+
/** The most: a slow read never makes a wait give up its last seconds. */
|
|
172
|
+
export declare const LAST_LOOK_MAX_MS = 2000;
|
|
173
|
+
/**
|
|
174
|
+
* A wait's polls, paced so the last goes out just before the deadline, with
|
|
175
|
+
* room for its answer: the slowest read of this wait so far plus
|
|
176
|
+
* LAST_LOOK_MS (at most LAST_LOOK_MAX_MS). Without it a wait whose last
|
|
177
|
+
* pause ran to the deadline gave up without looking, and one shorter than a
|
|
178
|
+
* poll interval never looked at all.
|
|
179
|
+
*/
|
|
180
|
+
export declare class Polls {
|
|
181
|
+
private readonly config;
|
|
182
|
+
private readonly deadline;
|
|
183
|
+
private lastLeft;
|
|
184
|
+
private sentAt;
|
|
185
|
+
private slowest;
|
|
186
|
+
constructor(config: Config, deadline: number);
|
|
187
|
+
private room;
|
|
188
|
+
/**
|
|
189
|
+
* How long to pause before the next poll: `delayMs`, cut so the poll goes
|
|
190
|
+
* out no later than the room it needs before the deadline (at once when
|
|
191
|
+
* less is left). null when the wait is over: the time is up, or the last
|
|
192
|
+
* look was taken (the previous poll went out with no more than that room).
|
|
193
|
+
*/
|
|
194
|
+
pause(delayMs: number): number | null;
|
|
195
|
+
/** A poll is going out now. */
|
|
196
|
+
sent(): void;
|
|
197
|
+
/** Its answer came back. */
|
|
198
|
+
answered(): void;
|
|
199
|
+
}
|
|
200
|
+
/** Delay before retry number `attempt` (0-based): exponential with jitter. */
|
|
201
|
+
export declare function backoffMs(config: Config, attempt: number): number;
|
|
202
|
+
/**
|
|
203
|
+
* What an error answer (not a 2xx: request() judges those) says about
|
|
204
|
+
* whether the sandbox acted: `never` for the codes that prove the request
|
|
205
|
+
* never arrived, `maybe` for any other 5xx, null for a refusal (4xx), whose
|
|
206
|
+
* own words say what happened.
|
|
207
|
+
*/
|
|
208
|
+
export declare function outcomeOf(error: PrizedError): Outcome;
|
|
209
|
+
/** One API or edge call, retried per `spec.retry`. Throws PrizedError. */
|
|
210
|
+
export declare function request(config: Config, spec: RequestSpec): Promise<HttpResult>;
|