@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,152 @@
1
+ /** One argument, quoted for a POSIX shell: as Python's shlex.quote does, so both SDKs send the same text. */
2
+ export declare function shellQuote(value: string): string;
3
+ /** A command as one string: a string is passed through, an argv array is quoted. */
4
+ export declare function commandText(command: string | readonly string[]): string;
5
+ /** The template's working directory: where relative paths and commands start. */
6
+ export declare const WORKSPACE_DIR = "/home/prized/work";
7
+ /** A path as the sandbox reads it: relative paths start in the workspace. */
8
+ export declare function sandboxPath(path: string): string;
9
+ export declare const RUNS_DIR = "/home/prized/.prized/runs";
10
+ export declare const RUN_ID_RE: RegExp;
11
+ /**
12
+ * Minutes (by the sandbox's clock) a cleaned or abandoned run's directory
13
+ * stays: what keeps a resend of its start from running the command again.
14
+ * The SDK stops resending a start within minutes, so an hour is ample; each
15
+ * cleanup then removes the ones older than that, so they never pile up.
16
+ */
17
+ export declare const CLEANED_KEEP_MIN = 60;
18
+ /** The start script's exit when it launched the command but no pid came within ten seconds: it may yet run. */
19
+ export declare const NO_PID_EXIT = 99;
20
+ /**
21
+ * The longest time limit a start script renders, in seconds (about 31
22
+ * years): a longer one is none in all but name, and past 2^63 the shell's
23
+ * arithmetic would wrap it negative and stop the command at once.
24
+ */
25
+ export declare const MAX_LIMIT_SEC = 1000000000;
26
+ /**
27
+ * The script that runs inside the run's session. Positional: $1 the run
28
+ * directory, $2 the shell, $3 the command, $4 the time limit in whole
29
+ * seconds ('' for none). No single quotes: it travels inside them.
30
+ *
31
+ * A signal sent to the run (HUP, INT, QUIT, TERM) does not end the wrapper,
32
+ * so it always writes `exit`: one that arrives before the launch means the
33
+ * command does not start (bar a window of microseconds just before the
34
+ * fork, where it reaches only the wrapper), and the run ends with that
35
+ * signal's 128+n code. One that arrives while the command runs reaches the
36
+ * command itself, in the same process group. USR1 and USR2 are the
37
+ * command's own to use.
38
+ *
39
+ * The caller's PATH (PRIZED_RUN_PATH, from the start script) is the
40
+ * command's alone: the wrapper and its watchdog keep the launcher's.
41
+ *
42
+ * The rest of the caller's env reaches the command as it was. A shell
43
+ * variable that came from the environment stays exported, so an assignment
44
+ * to it changes what every child is given: the scripts' own variables are
45
+ * __pz_ names (a caller's `d` once reached the command as the run's
46
+ * directory), and the scripts that lead to the command unset theirs first,
47
+ * so none is exported. Treat the prefix as theirs: a caller's variable
48
+ * named like one of the start script's or the wrapper's does not reach the
49
+ * command.
50
+ *
51
+ * The limit is a watchdog in the same process group, not timeout(1):
52
+ * timeout moves the command into a process group of its own, out of reach
53
+ * of a kill sent to the run. It ignores HUP, INT, QUIT, TERM, USR1 and USR2
54
+ * (so a kill of the run does not stop it first), checks once a second,
55
+ * writes `timedout`, sends TERM to the group, and five seconds later KILL to
56
+ * whatever is left of it. The limit and the command's end race, so `limit`
57
+ * is claimed once, by whichever comes first (mkdir is atomic): an end that
58
+ * came first stops the watchdog (only while its pid is still the
59
+ * watchdog's), and a limit that fired first is left to finish.
60
+ *
61
+ * Kept equal to the Python SDK's _WRAPPER (test/run-scripts.test.ts).
62
+ */
63
+ export declare const WRAPPER: string;
64
+ /** The PATH the start script launches with, whatever the caller's env says. */
65
+ export declare const LAUNCH_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
66
+ /**
67
+ * Start `command` detached. Prints the run's process group id. Safe to send
68
+ * twice with the same run id: the second send finds the directory and prints
69
+ * the first one's pid instead of starting it again.
70
+ *
71
+ * The caller's PATH reaches the command through the environment
72
+ * (PRIZED_RUN_PATH, not an argument, which every user of the guest could
73
+ * read); the launcher itself runs on LAUNCH_PATH, so a PATH of the
74
+ * caller's never hides setsid or the shell from it.
75
+ *
76
+ * `unbuffered` (background and streamed commands, whose output is read as
77
+ * it is printed) sets PYTHONUNBUFFERED=1 unless the command's environment
78
+ * already has it: Python's output to a file is otherwise held in a buffer
79
+ * until it exits.
80
+ *
81
+ * `setsid -f` forks the run itself: a shell's `&` would start it with INT
82
+ * and QUIT ignored, which nothing inside can undo (an ignored signal stays
83
+ * ignored across exec), so a kill with either would reach nothing. Without
84
+ * `-f` (util-linux before 2.31, BusyBox) it falls back to `&`, where INT and
85
+ * QUIT still reach nothing, as in the Python SDK.
86
+ */
87
+ export declare function startScript(runId: string, command: string, limitSec: number | null, unbuffered?: boolean): string;
88
+ /**
89
+ * Report a run's state and the next slice of each stream. Prints three lines:
90
+ *
91
+ * prized-run <running|done|gone|missing> <exit code|-> <stdout bytes> <stderr bytes> <timed out 0|1> <duration ms|->
92
+ * <base64 of stdout from outOffset, at most maxChunk bytes>
93
+ * <base64 of stderr from errOffset, at most maxChunk bytes>
94
+ *
95
+ * The state is read before the sizes, so a `done` report's sizes are final.
96
+ * Read-only: safe to repeat.
97
+ */
98
+ export declare function pollScript(runId: string, outOffset: number, errOffset: number, maxChunk: number): string;
99
+ /** Signal names kill() accepts (with or without the SIG prefix). */
100
+ export declare const SIGNALS: Set<string>;
101
+ /**
102
+ * Signal the run's whole process group. Exit 0 sent, 3 no such run, 4
103
+ * nothing left to signal: the run has ended.
104
+ *
105
+ * The group is named after the run's wrapper. Once the run has written its
106
+ * exit code it has ended, and nothing is sent (its wrapper may live a moment
107
+ * longer). While the wrapper's pid is alive, it must still be the wrapper
108
+ * (its command line names this run): a live process without it reused the
109
+ * pid once the run had ended (or been killed), its group is not this run's,
110
+ * and nothing is sent. Once the wrapper is gone, nothing proves the group
111
+ * is this run's any more.
112
+ */
113
+ export declare function killScript(runId: string, signal: string): string;
114
+ /**
115
+ * Remove a run's files once it has ended. A run still going keeps them
116
+ * (exit 5): its time limit's watchdog works from that directory, and so
117
+ * does every later status, wait or kill. Still going is what a status reads:
118
+ * no exit code yet, and the run's wrapper alive and still the run's. A pid
119
+ * or process group id reused since (after a restart of the sandbox, say)
120
+ * keeps nothing, or the files would never go; nor does what is left of the
121
+ * group once the wrapper is gone, which kill() no longer reaches either (a
122
+ * watchdog left in it still ends it at the time limit, even once the
123
+ * directory is gone). The directory itself stays, empty but for `cleaned`: it is
124
+ * what makes a start of the same run id start nothing, so a delayed resend
125
+ * of the start never runs the command twice. Not forever: each cleanup
126
+ * also removes the run directories marked `cleaned` more than
127
+ * CLEANED_KEEP_MIN minutes ago, and those left empty as long (by a start
128
+ * that never began), never one with a run's files in it.
129
+ */
130
+ export declare function cleanupScript(runId: string): string;
131
+ /**
132
+ * Stop a run whose start may or may not have happened (its answer was
133
+ * lost, or the caller left mid-start). If the run directory does not exist,
134
+ * make it, marked `cleaned`: a start of this run id that arrives later then
135
+ * finds it and starts nothing, and an abandon sent again knows it at once.
136
+ * If it exists, wait for the pid (the start writes it within its own ten
137
+ * seconds) and KILL the run's group. The directory is kept for the same
138
+ * reason, marked `cleaned`. Exit 0: the run can no longer
139
+ * start, was killed with its group, or had already ended. Exit 3: a start
140
+ * made the directory, but no pid came within ten seconds, so its command
141
+ * may yet run; nothing is marked. Safe to repeat.
142
+ */
143
+ export declare function abandonScript(runId: string): string;
144
+ /**
145
+ * List a directory, one NUL-terminated record per entry:
146
+ * `<type>\t<size>\t<mode>\t<mtime>\t<name>`. Exit 20 not a directory, 21 missing.
147
+ */
148
+ export declare function listScript(path: string): string;
149
+ /** Exit 0 when the path exists (a dangling link counts), 1 when it does not. */
150
+ export declare function existsScript(path: string): string;
151
+ /** Remove a path. Missing is not an error. */
152
+ export declare function removeScript(path: string, recursive: boolean): string;
@@ -0,0 +1,379 @@
1
+ // Shell text the SDK sends as commands. Everything a caller supplies is
2
+ // quoted with shellQuote; nothing else is interpolated but numbers and ids
3
+ // the SDK made itself.
4
+ /** One argument, quoted for a POSIX shell: as Python's shlex.quote does, so both SDKs send the same text. */
5
+ export function shellQuote(value) {
6
+ if (value === '')
7
+ return "''";
8
+ if (/^[A-Za-z0-9_\/.,:=@%+-]+$/.test(value))
9
+ return value;
10
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
11
+ }
12
+ /** A command as one string: a string is passed through, an argv array is quoted. */
13
+ export function commandText(command) {
14
+ if (typeof command === 'string')
15
+ return command;
16
+ if (command.length === 0)
17
+ return '';
18
+ return command.map(shellQuote).join(' ');
19
+ }
20
+ /** The template's working directory: where relative paths and commands start. */
21
+ export const WORKSPACE_DIR = '/home/prized/work';
22
+ /** A path as the sandbox reads it: relative paths start in the workspace. */
23
+ export function sandboxPath(path) {
24
+ if (path === '')
25
+ return WORKSPACE_DIR;
26
+ return path.startsWith('/') ? path : `${WORKSPACE_DIR}/${path.replace(/^\.\/+/, '')}`;
27
+ }
28
+ // ---------------------------------------------------------------------------
29
+ // Background commands. Each run gets a directory under the sandbox user's
30
+ // home, at a fixed path (a command's own HOME does not move it):
31
+ //
32
+ // ~/.prized/runs/<run id>/pid the run's process group id
33
+ // ~/.prized/runs/<run id>/stdout everything it printed, as it prints it
34
+ // ~/.prized/runs/<run id>/stderr
35
+ // ~/.prized/runs/<run id>/start when it started, epoch ms
36
+ // ~/.prized/runs/<run id>/end when it ended, epoch ms
37
+ // ~/.prized/runs/<run id>/exit its exit code, once it has one
38
+ // ~/.prized/runs/<run id>/timedout present when its time limit stopped it
39
+ // ~/.prized/runs/<run id>/watchdog the time limit's pid, when it has one
40
+ // ~/.prized/runs/<run id>/limit claimed once, by the limit or the command's end
41
+ // ~/.prized/runs/<run id>/cleaned all that is left once cleanup() ran
42
+ //
43
+ // The command runs in its own session (setsid), and what it starts stays in
44
+ // that one process group with the time-limit watchdog, unless it leaves it
45
+ // (setsid, a daemon): a kill reaches the group while the run lives, not a
46
+ // process that left it. The scripts are the Python SDK's (sdk/python
47
+ // src/prized/_runs.py), so either SDK can read a run the other started.
48
+ export const RUNS_DIR = '/home/prized/.prized/runs';
49
+ export const RUN_ID_RE = /^run_[0-9a-f]{16}$/;
50
+ /**
51
+ * Minutes (by the sandbox's clock) a cleaned or abandoned run's directory
52
+ * stays: what keeps a resend of its start from running the command again.
53
+ * The SDK stops resending a start within minutes, so an hour is ample; each
54
+ * cleanup then removes the ones older than that, so they never pile up.
55
+ */
56
+ export const CLEANED_KEEP_MIN = 60;
57
+ /** The start script's exit when it launched the command but no pid came within ten seconds: it may yet run. */
58
+ export const NO_PID_EXIT = 99;
59
+ /**
60
+ * The longest time limit a start script renders, in seconds (about 31
61
+ * years): a longer one is none in all but name, and past 2^63 the shell's
62
+ * arithmetic would wrap it negative and stop the command at once.
63
+ */
64
+ export const MAX_LIMIT_SEC = 1_000_000_000;
65
+ /**
66
+ * The script that runs inside the run's session. Positional: $1 the run
67
+ * directory, $2 the shell, $3 the command, $4 the time limit in whole
68
+ * seconds ('' for none). No single quotes: it travels inside them.
69
+ *
70
+ * A signal sent to the run (HUP, INT, QUIT, TERM) does not end the wrapper,
71
+ * so it always writes `exit`: one that arrives before the launch means the
72
+ * command does not start (bar a window of microseconds just before the
73
+ * fork, where it reaches only the wrapper), and the run ends with that
74
+ * signal's 128+n code. One that arrives while the command runs reaches the
75
+ * command itself, in the same process group. USR1 and USR2 are the
76
+ * command's own to use.
77
+ *
78
+ * The caller's PATH (PRIZED_RUN_PATH, from the start script) is the
79
+ * command's alone: the wrapper and its watchdog keep the launcher's.
80
+ *
81
+ * The rest of the caller's env reaches the command as it was. A shell
82
+ * variable that came from the environment stays exported, so an assignment
83
+ * to it changes what every child is given: the scripts' own variables are
84
+ * __pz_ names (a caller's `d` once reached the command as the run's
85
+ * directory), and the scripts that lead to the command unset theirs first,
86
+ * so none is exported. Treat the prefix as theirs: a caller's variable
87
+ * named like one of the start script's or the wrapper's does not reach the
88
+ * command.
89
+ *
90
+ * The limit is a watchdog in the same process group, not timeout(1):
91
+ * timeout moves the command into a process group of its own, out of reach
92
+ * of a kill sent to the run. It ignores HUP, INT, QUIT, TERM, USR1 and USR2
93
+ * (so a kill of the run does not stop it first), checks once a second,
94
+ * writes `timedout`, sends TERM to the group, and five seconds later KILL to
95
+ * whatever is left of it. The limit and the command's end race, so `limit`
96
+ * is claimed once, by whichever comes first (mkdir is atomic): an end that
97
+ * came first stops the watchdog (only while its pid is still the
98
+ * watchdog's), and a limit that fired first is left to finish.
99
+ *
100
+ * Kept equal to the Python SDK's _WRAPPER (test/run-scripts.test.ts).
101
+ */
102
+ export const WRAPPER = [
103
+ 'unset __pz_k __pz_u __pz_w __pz_c __pz_end',
104
+ '__pz_k=',
105
+ 'trap "__pz_k=129" HUP',
106
+ 'trap "__pz_k=130" INT',
107
+ 'trap "__pz_k=131" QUIT',
108
+ 'trap "__pz_k=143" TERM',
109
+ // A signal the command may use for itself must not end the wrapper.
110
+ 'trap ":" USR1 USR2',
111
+ // The caller's PATH is the command's alone; the wrapper keeps its own.
112
+ '__pz_u="${PRIZED_RUN_PATH-$PATH}"',
113
+ 'unset PRIZED_RUN_PATH',
114
+ // No published pid, no run: nothing could stop it.
115
+ 'echo "$$" > "$1/pid" || exit 1',
116
+ 'date +%s%3N > "$1/start"',
117
+ '__pz_w=',
118
+ 'if [ -n "$4" ]; then',
119
+ ' ( trap "" HUP INT QUIT TERM USR1 USR2',
120
+ // date, not bash's SECONDS: under /bin/sh (dash) SECONDS is unset and the
121
+ // limit would never fire.
122
+ ' __pz_end=$(($(date +%s) + $4 + 1))',
123
+ ' while [ ! -e "$1/exit" ]; do',
124
+ ' if [ "$(date +%s)" -ge "$__pz_end" ]; then',
125
+ // The limit and the command's end race: `limit` is claimed once, by
126
+ // whichever comes first (mkdir is atomic). An end that came first stops
127
+ // the watchdog; a limit that fired first is left to finish. Taken
128
+ // already means the command ended first; a mkdir that failed for want of
129
+ // disk claims nothing, and the limit still applies, as it does once the
130
+ // directory itself is gone (`true`, not `:`: under dash a failed
131
+ // redirection on a special builtin ends the subshell before its KILL).
132
+ ' mkdir "$1/limit" 2> /dev/null || [ ! -d "$1/limit" ] || exit 0',
133
+ ' true > "$1/timedout"',
134
+ ' kill -s TERM -- "-$$"',
135
+ // Always the full grace, then KILL: the command's shell may be gone while
136
+ // a child that ignores TERM is still running. The KILL takes the watchdog
137
+ // too; it is the last thing in the group.
138
+ ' sleep 5',
139
+ ' kill -s KILL -- "-$$"',
140
+ ' exit 0',
141
+ ' fi',
142
+ ' sleep 1',
143
+ ' done ) > /dev/null 2>&1 < /dev/null &',
144
+ ' __pz_w=$!',
145
+ ' echo "$__pz_w" > "$1/watchdog"',
146
+ 'fi',
147
+ 'if [ -n "$__pz_k" ]; then __pz_c=$__pz_k; else',
148
+ 'PATH="$__pz_u" "$2" -c "$3" > "$1/stdout" 2> "$1/stderr" < /dev/null',
149
+ '__pz_c=$?',
150
+ 'fi',
151
+ // The command ended within its limit: the limit no longer applies to
152
+ // anything it left running, so the watchdog is stopped before `exit`
153
+ // exists (no reader can delete the run while it waits to fire). A limit
154
+ // that already fired is left to finish: its KILL is what stops children
155
+ // that ignored the TERM.
156
+ 'if [ -n "$__pz_w" ] && { mkdir "$1/limit" 2> /dev/null || [ ! -d "$1/limit" ]; }; then ' +
157
+ // Only the watchdog itself (a subshell: it has the wrapper's command
158
+ // line): a pid it no longer holds is not signalled.
159
+ `${isRun('$__pz_w', '"prized-run $1 "')} ` +
160
+ '&& kill -s KILL "$__pz_w" 2> /dev/null; wait "$__pz_w" 2> /dev/null; fi',
161
+ 'date +%s%3N > "$1/end"',
162
+ 'echo "$__pz_c" > "$1/exit.tmp" && mv -f "$1/exit.tmp" "$1/exit"',
163
+ ].join('\n');
164
+ /** The PATH the start script launches with, whatever the caller's env says. */
165
+ export const LAUNCH_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
166
+ /**
167
+ * Start `command` detached. Prints the run's process group id. Safe to send
168
+ * twice with the same run id: the second send finds the directory and prints
169
+ * the first one's pid instead of starting it again.
170
+ *
171
+ * The caller's PATH reaches the command through the environment
172
+ * (PRIZED_RUN_PATH, not an argument, which every user of the guest could
173
+ * read); the launcher itself runs on LAUNCH_PATH, so a PATH of the
174
+ * caller's never hides setsid or the shell from it.
175
+ *
176
+ * `unbuffered` (background and streamed commands, whose output is read as
177
+ * it is printed) sets PYTHONUNBUFFERED=1 unless the command's environment
178
+ * already has it: Python's output to a file is otherwise held in a buffer
179
+ * until it exits.
180
+ *
181
+ * `setsid -f` forks the run itself: a shell's `&` would start it with INT
182
+ * and QUIT ignored, which nothing inside can undo (an ignored signal stays
183
+ * ignored across exec), so a kill with either would reach nothing. Without
184
+ * `-f` (util-linux before 2.31, BusyBox) it falls back to `&`, where INT and
185
+ * QUIT still reach nothing, as in the Python SDK.
186
+ */
187
+ export function startScript(runId, command, limitSec, unbuffered = false) {
188
+ const limit = limitSec === null ? "''" : String(Math.min(Math.max(1, Math.ceil(limitSec)), MAX_LIMIT_SEC));
189
+ const run = `prized-run "$__pz_d" "$__pz_s" ${shellQuote(command)} ${limit} > /dev/null 2>&1 < /dev/null`;
190
+ return [
191
+ // The caller's env goes to the command as it is (see WRAPPER).
192
+ 'unset __pz_r __pz_d __pz_s __pz_w __pz_n',
193
+ 'export PRIZED_RUN_PATH="${PATH-}"',
194
+ `PATH=${LAUNCH_PATH}`,
195
+ ...(unbuffered ? ['export PYTHONUNBUFFERED="${PYTHONUNBUFFERED-1}"'] : []),
196
+ `__pz_r=${RUNS_DIR}; __pz_d="$__pz_r/${runId}"`,
197
+ `command -v setsid > /dev/null 2>&1 || { echo 'setsid is not installed in this sandbox' >&2; exit 97; }`,
198
+ 'mkdir -p "$__pz_r" || exit 98',
199
+ 'if mkdir "$__pz_d" 2> /dev/null; then',
200
+ ' __pz_s=/bin/bash; [ -x "$__pz_s" ] || __pz_s=/bin/sh',
201
+ ` __pz_w='${WRAPPER}'`,
202
+ ' if setsid -f true > /dev/null 2>&1; then',
203
+ ` setsid -f "$__pz_s" -c "$__pz_w" ${run}`,
204
+ ' else',
205
+ ` setsid "$__pz_s" -c "$__pz_w" ${run} &`,
206
+ ' fi',
207
+ 'fi',
208
+ `if [ -f "$__pz_d/cleaned" ]; then echo 'this run was already cleaned up' >&2; exit 96; fi`,
209
+ '__pz_n=0',
210
+ 'while [ ! -s "$__pz_d/pid" ]; do',
211
+ ' __pz_n=$((__pz_n + 1))',
212
+ ` if [ "$__pz_n" -gt 200 ]; then echo 'the command did not start' >&2; exit ${NO_PID_EXIT}; fi`,
213
+ ' sleep 0.05',
214
+ 'done',
215
+ 'cat "$__pz_d/pid"',
216
+ ].join('\n');
217
+ }
218
+ /**
219
+ * A shell test: process `pid` (shell text) is a run's wrapper, or its
220
+ * watchdog (a subshell, with the wrapper's command line), not another
221
+ * process that reused the pid. The wrapper's arguments are `<shell> -c
222
+ * <wrapper> prized-run <run directory> ...`: its fourth and fifth must be
223
+ * exactly `expected` (shell text for "prized-run <run directory> "), not
224
+ * merely somewhere in its command line, where another run's command may
225
+ * name them too (`tail -f ~/.prized/runs/<id>/stdout`). /proc ends each
226
+ * argument with a NUL; here each is one line (a NUL becomes a newline, a
227
+ * newline a space).
228
+ */
229
+ function isRun(pid, expected) {
230
+ return `[ "$(tr "\\0\\n" "\\n " < "/proc/${pid}/cmdline" 2> /dev/null | sed -n 4,5p | tr "\\n" " ")" = ${expected} ]`;
231
+ }
232
+ /** isRun for the run `runId`, whose pid is in $__pz_p. */
233
+ function ours(runId) {
234
+ return isRun('$__pz_p', shellQuote(`prized-run ${RUNS_DIR}/${runId} `));
235
+ }
236
+ /**
237
+ * Report a run's state and the next slice of each stream. Prints three lines:
238
+ *
239
+ * prized-run <running|done|gone|missing> <exit code|-> <stdout bytes> <stderr bytes> <timed out 0|1> <duration ms|->
240
+ * <base64 of stdout from outOffset, at most maxChunk bytes>
241
+ * <base64 of stderr from errOffset, at most maxChunk bytes>
242
+ *
243
+ * The state is read before the sizes, so a `done` report's sizes are final.
244
+ * Read-only: safe to repeat.
245
+ */
246
+ export function pollScript(runId, outOffset, errOffset, maxChunk) {
247
+ const slice = (file, offset, size) => [
248
+ `__pz_n=$((${size} - ${offset})); [ "$__pz_n" -gt ${maxChunk} ] && __pz_n=${maxChunk}`,
249
+ `if [ "$__pz_n" -gt 0 ]; then tail -c +${offset + 1} "$__pz_d/${file}" | head -c "$__pz_n" | base64 -w 0; fi`,
250
+ 'echo',
251
+ ];
252
+ return [
253
+ `__pz_d=${RUNS_DIR}/${runId}`,
254
+ 'if [ ! -d "$__pz_d" ] || [ -f "$__pz_d/cleaned" ]; then echo "prized-run missing - 0 0 0 -"; exit 0; fi',
255
+ 'if [ -f "$__pz_d/exit" ]; then __pz_st=done; __pz_c=$(cat "$__pz_d/exit")',
256
+ // Running while the run's wrapper lives and is still the run's, not
257
+ // another process that reused its pid once the run was killed.
258
+ `elif __pz_p=$(cat "$__pz_d/pid" 2> /dev/null) && [ -n "$__pz_p" ] && kill -0 "$__pz_p" 2> /dev/null && ${ours(runId)}; then __pz_st=running; __pz_c=-`,
259
+ 'elif [ -f "$__pz_d/exit" ]; then __pz_st=done; __pz_c=$(cat "$__pz_d/exit")',
260
+ 'else __pz_st=gone; __pz_c=-',
261
+ 'fi',
262
+ '__pz_to=0; [ -e "$__pz_d/timedout" ] && __pz_to=1',
263
+ '__pz_du=-',
264
+ 'if [ -f "$__pz_d/end" ]; then',
265
+ ' __pz_a=$(cat "$__pz_d/start" 2> /dev/null); __pz_b=$(cat "$__pz_d/end" 2> /dev/null)',
266
+ ' case "$__pz_a:$__pz_b" in *[!0-9:]* | :* | *:) ;; *) __pz_du=$((__pz_b - __pz_a)) ;; esac',
267
+ 'fi',
268
+ '__pz_o=$(stat -c %s "$__pz_d/stdout" 2> /dev/null || echo 0)',
269
+ '__pz_e=$(stat -c %s "$__pz_d/stderr" 2> /dev/null || echo 0)',
270
+ 'echo "prized-run $__pz_st $__pz_c $__pz_o $__pz_e $__pz_to $__pz_du"',
271
+ ...slice('stdout', outOffset, '$__pz_o'),
272
+ ...slice('stderr', errOffset, '$__pz_e'),
273
+ ].join('\n');
274
+ }
275
+ /** Signal names kill() accepts (with or without the SIG prefix). */
276
+ export const SIGNALS = new Set(['TERM', 'KILL', 'INT', 'HUP', 'QUIT', 'USR1', 'USR2', 'STOP', 'CONT']);
277
+ /**
278
+ * Signal the run's whole process group. Exit 0 sent, 3 no such run, 4
279
+ * nothing left to signal: the run has ended.
280
+ *
281
+ * The group is named after the run's wrapper. Once the run has written its
282
+ * exit code it has ended, and nothing is sent (its wrapper may live a moment
283
+ * longer). While the wrapper's pid is alive, it must still be the wrapper
284
+ * (its command line names this run): a live process without it reused the
285
+ * pid once the run had ended (or been killed), its group is not this run's,
286
+ * and nothing is sent. Once the wrapper is gone, nothing proves the group
287
+ * is this run's any more.
288
+ */
289
+ export function killScript(runId, signal) {
290
+ const d = `${RUNS_DIR}/${runId}`;
291
+ return [
292
+ `__pz_p=$(cat ${d}/pid 2> /dev/null) && [ -n "$__pz_p" ] || exit 3`,
293
+ `[ ! -f ${d}/exit ] && kill -0 "$__pz_p" 2> /dev/null && ${ours(runId)} || exit 4`,
294
+ `kill -s ${signal} -- "-$__pz_p" 2> /dev/null || exit 4`,
295
+ ].join('\n');
296
+ }
297
+ /**
298
+ * Remove a run's files once it has ended. A run still going keeps them
299
+ * (exit 5): its time limit's watchdog works from that directory, and so
300
+ * does every later status, wait or kill. Still going is what a status reads:
301
+ * no exit code yet, and the run's wrapper alive and still the run's. A pid
302
+ * or process group id reused since (after a restart of the sandbox, say)
303
+ * keeps nothing, or the files would never go; nor does what is left of the
304
+ * group once the wrapper is gone, which kill() no longer reaches either (a
305
+ * watchdog left in it still ends it at the time limit, even once the
306
+ * directory is gone). The directory itself stays, empty but for `cleaned`: it is
307
+ * what makes a start of the same run id start nothing, so a delayed resend
308
+ * of the start never runs the command twice. Not forever: each cleanup
309
+ * also removes the run directories marked `cleaned` more than
310
+ * CLEANED_KEEP_MIN minutes ago, and those left empty as long (by a start
311
+ * that never began), never one with a run's files in it.
312
+ */
313
+ export function cleanupScript(runId) {
314
+ const d = `${RUNS_DIR}/${runId}`;
315
+ return [
316
+ `if [ ! -f ${d}/exit ] && __pz_p=$(cat ${d}/pid 2> /dev/null) && [ -n "$__pz_p" ] && kill -0 "$__pz_p" 2> /dev/null && ${ours(runId)}; then exit 5; fi`,
317
+ `find ${RUNS_DIR} -mindepth 2 -maxdepth 2 -path '${RUNS_DIR}/run_*/cleaned' -mmin +${CLEANED_KEEP_MIN} -printf '%h\\0' 2> /dev/null | xargs -0 -r rm -rf --`,
318
+ `find ${RUNS_DIR} -mindepth 1 -maxdepth 1 -type d -name 'run_*' -empty -mmin +${CLEANED_KEEP_MIN} -delete 2> /dev/null`,
319
+ `[ -d ${d} ] || exit 0`,
320
+ `find ${d} -mindepth 1 -maxdepth 1 ! -name cleaned -exec rm -rf -- {} +`,
321
+ `: > ${d}/cleaned`,
322
+ ].join('\n');
323
+ }
324
+ /**
325
+ * Stop a run whose start may or may not have happened (its answer was
326
+ * lost, or the caller left mid-start). If the run directory does not exist,
327
+ * make it, marked `cleaned`: a start of this run id that arrives later then
328
+ * finds it and starts nothing, and an abandon sent again knows it at once.
329
+ * If it exists, wait for the pid (the start writes it within its own ten
330
+ * seconds) and KILL the run's group. The directory is kept for the same
331
+ * reason, marked `cleaned`. Exit 0: the run can no longer
332
+ * start, was killed with its group, or had already ended. Exit 3: a start
333
+ * made the directory, but no pid came within ten seconds, so its command
334
+ * may yet run; nothing is marked. Safe to repeat.
335
+ */
336
+ export function abandonScript(runId) {
337
+ return [
338
+ `__pz_d=${RUNS_DIR}/${runId}`,
339
+ `mkdir -p ${RUNS_DIR} 2> /dev/null`,
340
+ 'if { mkdir "$__pz_d" 2> /dev/null && true > "$__pz_d/cleaned"; } || [ -f "$__pz_d/cleaned" ]; then exit 0; fi',
341
+ '__pz_n=0',
342
+ 'while [ ! -s "$__pz_d/pid" ]; do',
343
+ ' __pz_n=$((__pz_n + 1))',
344
+ ' if [ "$__pz_n" -gt 200 ]; then exit 3; fi',
345
+ ' sleep 0.05',
346
+ 'done',
347
+ '__pz_p=$(cat "$__pz_d/pid")',
348
+ `if kill -0 "$__pz_p" 2> /dev/null && ${ours(runId)}; then kill -s KILL -- "-$__pz_p" 2> /dev/null; fi`,
349
+ // Its files go, its directory stays (marked `cleaned`): a start of
350
+ // this id that arrives later still starts nothing.
351
+ 'find "$__pz_d" -mindepth 1 -maxdepth 1 ! -name cleaned -exec rm -rf -- {} +',
352
+ ': > "$__pz_d/cleaned"',
353
+ 'exit 0',
354
+ ].join('\n');
355
+ }
356
+ // ---------------------------------------------------------------------------
357
+ // Files the edge has no route for, done with a command.
358
+ /**
359
+ * List a directory, one NUL-terminated record per entry:
360
+ * `<type>\t<size>\t<mode>\t<mtime>\t<name>`. Exit 20 not a directory, 21 missing.
361
+ */
362
+ export function listScript(path) {
363
+ const p = shellQuote(path);
364
+ return [
365
+ `if [ ! -d ${p} ]; then if [ -e ${p} ] || [ -L ${p} ]; then exit 20; fi; exit 21; fi`,
366
+ // -H: a link given as the directory is followed (the -d test above
367
+ // already did); links inside it are listed as links, never followed.
368
+ `find -H ${p} -mindepth 1 -maxdepth 1 -printf '%y\\t%s\\t%M\\t%T@\\t%f\\0'`,
369
+ ].join('\n');
370
+ }
371
+ /** Exit 0 when the path exists (a dangling link counts), 1 when it does not. */
372
+ export function existsScript(path) {
373
+ const p = shellQuote(path);
374
+ return `if [ -e ${p} ] || [ -L ${p} ]; then exit 0; fi; exit 1`;
375
+ }
376
+ /** Remove a path. Missing is not an error. */
377
+ export function removeScript(path, recursive) {
378
+ return `rm ${recursive ? '-rf' : '-f'} -- ${shellQuote(path)}`;
379
+ }
@@ -0,0 +1,48 @@
1
+ import { type Config, type RetryMode } from './http.js';
2
+ import { type Effect } from './outcome.js';
3
+ import type { ExecResult } from './types.js';
4
+ /** What the SDK needs to reach one sandbox's exec and files. */
5
+ export interface Toolbox {
6
+ readonly config: Config;
7
+ readonly sandboxId: string;
8
+ /** `https://…/v1/sandbox/<id>`, re-read on every call. */
9
+ base(): string;
10
+ }
11
+ /** Each stream of one call keeps at most this many bytes. */
12
+ export declare const MAX_OUTPUT_BYTES: number;
13
+ /** One upload or download moves at most this many bytes. */
14
+ export declare const MAX_FILE_BYTES: number;
15
+ export interface RawExecRequest {
16
+ command: string;
17
+ cwd?: string;
18
+ env?: Record<string, string>;
19
+ /**
20
+ * ms. Always sent: the sandbox's own default (5 minutes) is exactly how
21
+ * long Node's fetch waits for an answer's headers, so a call that left it
22
+ * to the sandbox would fail at the very moment the command's answer came.
23
+ */
24
+ timeoutMs: number;
25
+ }
26
+ /** One command over the edge, answered when it finishes. */
27
+ export declare function rawExec(toolbox: Toolbox, req: RawExecRequest, options: {
28
+ retry: RetryMode;
29
+ signal?: AbortSignal | undefined;
30
+ deadline?: number | undefined;
31
+ /** What the command does, for an error that says whether it happened (`exec`: a command unless given). */
32
+ effect?: Effect | undefined;
33
+ }): Promise<ExecResult>;
34
+ /** A helper command the SDK sends itself; a non-zero exit is the caller's to judge. */
35
+ export declare function helperExec(toolbox: Toolbox, command: string, options?: {
36
+ signal?: AbortSignal | undefined;
37
+ timeoutMs?: number;
38
+ /**
39
+ * `safe` (the default) for helpers that may be repeated: read-only, or
40
+ * keyed by an id the SDK chose. A destructive helper (rm) passes `exec`,
41
+ * so a lost answer is never sent a second time.
42
+ */
43
+ retry?: RetryMode;
44
+ /** Epoch ms (config.now()) past which neither the attempt nor a retry may run. */
45
+ deadline?: number | undefined;
46
+ /** What a destructive helper does, for its errors (outcome.ts). */
47
+ effect?: Effect | undefined;
48
+ }): Promise<ExecResult>;
@@ -0,0 +1,83 @@
1
+ // The exec/files edge for one sandbox: POST {base}/process/execute,
2
+ // POST {base}/files/upload?path=, GET {base}/files/download?path=.
3
+ import { PrizedError } from './errors.js';
4
+ import { request } from './http.js';
5
+ import { commandEffect, helperEffect, withOutcome } from './outcome.js';
6
+ /** Each stream of one call keeps at most this many bytes. */
7
+ export const MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
8
+ /** One upload or download moves at most this many bytes. */
9
+ export const MAX_FILE_BYTES = 100 * 1024 * 1024;
10
+ /** Slack on top of a command's own timeout before the SDK stops waiting for the answer. */
11
+ const EXEC_ANSWER_SLACK_MS = 30_000;
12
+ /**
13
+ * How many of the stream's bytes the sandbox kept, from the text its answer
14
+ * carried. The sandbox keeps exactly the first MAX_OUTPUT_BYTES; each byte
15
+ * of that which was not valid UTF-8 arrives as one U+FFFD, so U+FFFD counts
16
+ * as one byte here, not the three it encodes to (as the Python SDK counts):
17
+ * 2 MiB of binary output is not a full stream.
18
+ */
19
+ function keptBytes(text) {
20
+ // Fast path: fewer than MAX/3 UTF-16 units can never encode to MAX bytes.
21
+ if (text.length * 3 < MAX_OUTPUT_BYTES)
22
+ return text.length;
23
+ let replaced = 0;
24
+ for (let at = text.indexOf('\uFFFD'); at !== -1; at = text.indexOf('\uFFFD', at + 1))
25
+ replaced += 1;
26
+ return new TextEncoder().encode(text).length - 2 * replaced;
27
+ }
28
+ /** One command over the edge, answered when it finishes. */
29
+ export async function rawExec(toolbox, req, options) {
30
+ if (!(req.timeoutMs > 0))
31
+ throw new PrizedError('invalid_argument', 'A command sent on one call needs a timeout > 0 ms');
32
+ const effect = options.effect ?? (options.retry === 'exec' ? commandEffect() : undefined);
33
+ const body = { command: req.command };
34
+ if (req.cwd !== undefined)
35
+ body.cwd = req.cwd;
36
+ if (req.env !== undefined && Object.keys(req.env).length > 0)
37
+ body.env = req.env;
38
+ body.timeout = req.timeoutMs / 1000;
39
+ const result = await request(toolbox.config, {
40
+ method: 'POST',
41
+ url: `${toolbox.base()}/process/execute`,
42
+ json: body,
43
+ retry: options.retry,
44
+ timeoutMs: req.timeoutMs + EXEC_ANSWER_SLACK_MS,
45
+ signal: options.signal,
46
+ deadline: options.deadline,
47
+ sandboxId: toolbox.sandboxId,
48
+ ...(effect === undefined ? {} : { effect }),
49
+ });
50
+ const doc = result.json;
51
+ if (doc === null || typeof doc !== 'object' || typeof doc.exitCode !== 'number') {
52
+ const unread = new PrizedError('bad_response', 'The exec answer carried no exit code.', {
53
+ status: result.status,
54
+ sandboxId: toolbox.sandboxId,
55
+ });
56
+ // A 2xx: the command ran, whatever its answer lacks.
57
+ throw effect === undefined ? unread : withOutcome(unread, effect, 'did');
58
+ }
59
+ const stdout = typeof doc.stdout === 'string' ? doc.stdout : '';
60
+ const stderr = typeof doc.stderr === 'string' ? doc.stderr : '';
61
+ const truncated = keptBytes(stdout) >= MAX_OUTPUT_BYTES || keptBytes(stderr) >= MAX_OUTPUT_BYTES;
62
+ return {
63
+ stdout,
64
+ stderr,
65
+ exitCode: doc.exitCode,
66
+ durationMs: typeof doc.durationMs === 'number' ? doc.durationMs : 0,
67
+ timedOut: doc.timedOut === true,
68
+ truncated,
69
+ ...(truncated ? { truncatedReason: 'output_limit' } : {}),
70
+ };
71
+ }
72
+ /** A helper command the SDK sends itself; a non-zero exit is the caller's to judge. */
73
+ export function helperExec(toolbox, command, options = {}) {
74
+ const retry = options.retry ?? 'safe';
75
+ return rawExec(toolbox, { command, timeoutMs: options.timeoutMs ?? 60_000 }, {
76
+ retry,
77
+ signal: options.signal,
78
+ deadline: options.deadline,
79
+ // Not the caller's command: an `exec` helper that names no effect (a
80
+ // kill) gets words that claim nothing about what it does.
81
+ effect: options.effect ?? (retry === 'exec' ? helperEffect() : undefined),
82
+ });
83
+ }