@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.
Files changed (52) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +666 -2
  3. package/dist/cjs/commands.d.ts +199 -0
  4. package/dist/cjs/commands.js +597 -0
  5. package/dist/cjs/errors.d.ts +113 -0
  6. package/dist/cjs/errors.js +306 -0
  7. package/dist/cjs/files.d.ts +101 -0
  8. package/dist/cjs/files.js +387 -0
  9. package/dist/cjs/http.d.ts +226 -0
  10. package/dist/cjs/http.js +618 -0
  11. package/dist/cjs/index.d.ts +12 -0
  12. package/dist/cjs/index.js +23 -0
  13. package/dist/cjs/outcome.d.ts +76 -0
  14. package/dist/cjs/outcome.js +170 -0
  15. package/dist/cjs/output.d.ts +70 -0
  16. package/dist/cjs/output.js +383 -0
  17. package/dist/cjs/package.json +1 -0
  18. package/dist/cjs/sandbox.d.ts +218 -0
  19. package/dist/cjs/sandbox.js +836 -0
  20. package/dist/cjs/shell.d.ts +152 -0
  21. package/dist/cjs/shell.js +393 -0
  22. package/dist/cjs/toolbox.d.ts +48 -0
  23. package/dist/cjs/toolbox.js +88 -0
  24. package/dist/cjs/types.d.ts +111 -0
  25. package/dist/cjs/types.js +102 -0
  26. package/dist/cjs/version.d.ts +2 -0
  27. package/dist/cjs/version.js +5 -0
  28. package/dist/esm/commands.d.ts +199 -0
  29. package/dist/esm/commands.js +590 -0
  30. package/dist/esm/errors.d.ts +113 -0
  31. package/dist/esm/errors.js +297 -0
  32. package/dist/esm/files.d.ts +101 -0
  33. package/dist/esm/files.js +381 -0
  34. package/dist/esm/http.d.ts +226 -0
  35. package/dist/esm/http.js +601 -0
  36. package/dist/esm/index.d.ts +12 -0
  37. package/dist/esm/index.js +7 -0
  38. package/dist/esm/outcome.d.ts +76 -0
  39. package/dist/esm/outcome.js +155 -0
  40. package/dist/esm/output.d.ts +70 -0
  41. package/dist/esm/output.js +374 -0
  42. package/dist/esm/sandbox.d.ts +218 -0
  43. package/dist/esm/sandbox.js +832 -0
  44. package/dist/esm/shell.d.ts +152 -0
  45. package/dist/esm/shell.js +379 -0
  46. package/dist/esm/toolbox.d.ts +48 -0
  47. package/dist/esm/toolbox.js +83 -0
  48. package/dist/esm/types.d.ts +111 -0
  49. package/dist/esm/types.js +95 -0
  50. package/dist/esm/version.d.ts +2 -0
  51. package/dist/esm/version.js +2 -0
  52. 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>;