@prized/sdk 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +625 -2
- package/dist/cjs/commands.d.ts +168 -0
- package/dist/cjs/commands.js +567 -0
- package/dist/cjs/errors.d.ts +73 -0
- package/dist/cjs/errors.js +258 -0
- package/dist/cjs/files.d.ts +86 -0
- package/dist/cjs/files.js +366 -0
- package/dist/cjs/http.d.ts +210 -0
- package/dist/cjs/http.js +570 -0
- package/dist/cjs/index.d.ts +12 -0
- package/dist/cjs/index.js +22 -0
- package/dist/cjs/outcome.d.ts +75 -0
- package/dist/cjs/outcome.js +167 -0
- package/dist/cjs/output.d.ts +59 -0
- package/dist/cjs/output.js +288 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/sandbox.d.ts +218 -0
- package/dist/cjs/sandbox.js +810 -0
- package/dist/cjs/shell.d.ts +152 -0
- package/dist/cjs/shell.js +393 -0
- package/dist/cjs/toolbox.d.ts +48 -0
- package/dist/cjs/toolbox.js +86 -0
- package/dist/cjs/types.d.ts +99 -0
- package/dist/cjs/types.js +102 -0
- package/dist/cjs/version.d.ts +2 -0
- package/dist/cjs/version.js +5 -0
- package/dist/esm/commands.d.ts +168 -0
- package/dist/esm/commands.js +560 -0
- package/dist/esm/errors.d.ts +73 -0
- package/dist/esm/errors.js +250 -0
- package/dist/esm/files.d.ts +86 -0
- package/dist/esm/files.js +360 -0
- package/dist/esm/http.d.ts +210 -0
- package/dist/esm/http.js +553 -0
- package/dist/esm/index.d.ts +12 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/outcome.d.ts +75 -0
- package/dist/esm/outcome.js +152 -0
- package/dist/esm/output.d.ts +59 -0
- package/dist/esm/output.js +280 -0
- package/dist/esm/sandbox.d.ts +218 -0
- package/dist/esm/sandbox.js +806 -0
- package/dist/esm/shell.d.ts +152 -0
- package/dist/esm/shell.js +379 -0
- package/dist/esm/toolbox.d.ts +48 -0
- package/dist/esm/toolbox.js +81 -0
- package/dist/esm/types.d.ts +99 -0
- package/dist/esm/types.js +95 -0
- package/dist/esm/version.d.ts +2 -0
- package/dist/esm/version.js +2 -0
- package/package.json +53 -4
|
@@ -0,0 +1,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,81 @@
|
|
|
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
|
+
return {
|
|
62
|
+
stdout,
|
|
63
|
+
stderr,
|
|
64
|
+
exitCode: doc.exitCode,
|
|
65
|
+
durationMs: typeof doc.durationMs === 'number' ? doc.durationMs : 0,
|
|
66
|
+
timedOut: doc.timedOut === true,
|
|
67
|
+
truncated: keptBytes(stdout) >= MAX_OUTPUT_BYTES || keptBytes(stderr) >= MAX_OUTPUT_BYTES,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** A helper command the SDK sends itself; a non-zero exit is the caller's to judge. */
|
|
71
|
+
export function helperExec(toolbox, command, options = {}) {
|
|
72
|
+
const retry = options.retry ?? 'safe';
|
|
73
|
+
return rawExec(toolbox, { command, timeoutMs: options.timeoutMs ?? 60_000 }, {
|
|
74
|
+
retry,
|
|
75
|
+
signal: options.signal,
|
|
76
|
+
deadline: options.deadline,
|
|
77
|
+
// Not the caller's command: an `exec` helper that names no effect (a
|
|
78
|
+
// kill) gets words that claim nothing about what it does.
|
|
79
|
+
effect: options.effect ?? (retry === 'exec' ? helperEffect() : undefined),
|
|
80
|
+
});
|
|
81
|
+
}
|