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