kern-sandbox 0.1.2 → 0.1.3
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/README.md +17 -7
- package/index.d.ts +25 -0
- package/index.js +170 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -77,6 +77,7 @@ const r = await kern.runCode("console.log([1,2,3].map(x => x * x))", {
|
|
|
77
77
|
| `success` | `true` iff `exitCode === 0` **and** no sandbox fault |
|
|
78
78
|
| `fault` | a sandbox event, or `null`. `{ type, message }` |
|
|
79
79
|
| `files` | files created/modified in the workspace this call |
|
|
80
|
+
| `results` | rich mime-typed values (`Result[]`): last expression, `display()`, matplotlib figures |
|
|
80
81
|
| `truncated` | output hit the cap and overflow was discarded |
|
|
81
82
|
|
|
82
83
|
A non-zero exit from *your code* is **not** a fault (`fault` stays `null`): it is a normal result.
|
|
@@ -134,19 +135,28 @@ new Sandbox({
|
|
|
134
135
|
});
|
|
135
136
|
```
|
|
136
137
|
|
|
137
|
-
## Charts, live output, and checkpoints
|
|
138
|
+
## Charts, rich results, live output, and checkpoints
|
|
138
139
|
|
|
139
|
-
**
|
|
140
|
-
|
|
140
|
+
**Rich results (the "code interpreter" pattern).** `runCode` runs Python by default, and like a
|
|
141
|
+
Jupyter/E2B cell it captures rich, mime-typed values into `result.results` (a list of `Result`) with
|
|
142
|
+
**no Jupyter kernel**: the value of the code's **last bare expression**, every **`display(obj)`** call,
|
|
143
|
+
and **every open matplotlib figure automatically** (no `savefig`). Accessors: `.png`/`.jpeg` (Buffer),
|
|
144
|
+
`.html`, `.svg`, `.markdown`, `.json`, `.text`.
|
|
141
145
|
|
|
142
146
|
```js
|
|
143
|
-
await kern.withSandbox({ setup: "pip install matplotlib" }, async (sbx) => {
|
|
144
|
-
await sbx.runCode("import matplotlib; matplotlib.use('Agg')\n" +
|
|
145
|
-
"import matplotlib.pyplot as plt; plt.plot([1,4,9])
|
|
146
|
-
const png =
|
|
147
|
+
await kern.withSandbox({ setup: "pip install matplotlib pandas" }, async (sbx) => {
|
|
148
|
+
let r = await sbx.runCode("import matplotlib; matplotlib.use('Agg')\n" +
|
|
149
|
+
"import matplotlib.pyplot as plt; plt.plot([1,4,9])");
|
|
150
|
+
const png = r.results[0].png; // Buffer of the figure, auto-captured
|
|
151
|
+
|
|
152
|
+
r = await sbx.runCode("import pandas as pd; pd.DataFrame({'a':[1,2]})");
|
|
153
|
+
r.results[0].html; // the DataFrame as an HTML table (also .text)
|
|
147
154
|
});
|
|
148
155
|
```
|
|
149
156
|
|
|
157
|
+
Capture never touches `stdout`/`stderr`/`exitCode`; a statement returning `None` yields no result. You
|
|
158
|
+
can still WRITE an artifact to the workspace and `readFile` it if you prefer.
|
|
159
|
+
|
|
150
160
|
**Live output.** Pass `onStdout` / `onStderr` to stream each chunk as it arrives. The callback is
|
|
151
161
|
best-effort, not lossless: a SLOW callback drops chunks rather than applying backpressure to the box
|
|
152
162
|
(the full capped output is always in `result.stdout`).
|
package/index.d.ts
CHANGED
|
@@ -17,6 +17,29 @@ export interface FileInfo {
|
|
|
17
17
|
change: "created" | "modified";
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** A rich, mime-typed value captured from a Python `runCode` (Jupyter/E2B-style): the code's last bare
|
|
21
|
+
* expression, every `display(obj)` call, and every open matplotlib figure. `data` maps a MIME type to
|
|
22
|
+
* its payload (text/* and application/json are strings; image/* are base64). One value, several forms. */
|
|
23
|
+
export class Result {
|
|
24
|
+
data: Record<string, string>;
|
|
25
|
+
/** text/plain */
|
|
26
|
+
readonly text?: string;
|
|
27
|
+
/** text/html */
|
|
28
|
+
readonly html?: string;
|
|
29
|
+
/** text/markdown */
|
|
30
|
+
readonly markdown?: string;
|
|
31
|
+
/** image/svg+xml */
|
|
32
|
+
readonly svg?: string;
|
|
33
|
+
/** application/json */
|
|
34
|
+
readonly json?: string;
|
|
35
|
+
/** image/png decoded to a Buffer, or null */
|
|
36
|
+
readonly png: Buffer | null;
|
|
37
|
+
/** image/jpeg decoded to a Buffer, or null */
|
|
38
|
+
readonly jpeg: Buffer | null;
|
|
39
|
+
/** The MIME types this value was captured as. */
|
|
40
|
+
formats(): string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
20
43
|
/** The outcome of one runCode()/run(). `fault` is the source of truth for "did the sandbox act";
|
|
21
44
|
* `exitCode`/`stdout` are what the user's code did. `success` requires both clean. */
|
|
22
45
|
export class ExecutionResult {
|
|
@@ -29,6 +52,8 @@ export class ExecutionResult {
|
|
|
29
52
|
files: FileInfo[];
|
|
30
53
|
/** stdout/stderr hit the capture cap and overflow was discarded. */
|
|
31
54
|
truncated: boolean;
|
|
55
|
+
/** Rich mime-typed values (Python runCode): last expression, display(), matplotlib figures. */
|
|
56
|
+
results: Result[];
|
|
32
57
|
/** True iff the code exited 0 AND no sandbox fault fired. */
|
|
33
58
|
readonly success: boolean;
|
|
34
59
|
}
|
package/index.js
CHANGED
|
@@ -36,7 +36,7 @@ const crypto = require("crypto");
|
|
|
36
36
|
const zlib = require("zlib");
|
|
37
37
|
const { spawn, spawnSync } = require("child_process");
|
|
38
38
|
|
|
39
|
-
const VERSION = "0.1.
|
|
39
|
+
const VERSION = "0.1.3";
|
|
40
40
|
|
|
41
41
|
const DEFAULT_IMAGE = "python:3.12-slim";
|
|
42
42
|
const WORKSPACE = "/workspace"; // where the persistent workspace is mounted inside every box
|
|
@@ -44,6 +44,95 @@ const DEPS_DIR = ".deps"; // pip --target dir inside the workspace (added to PYT
|
|
|
44
44
|
const ENV_FILE = ".kern-env"; // host-side 0600 env file (kept out of argv so values don't show in `ps`)
|
|
45
45
|
const INLINE_CODE_MAX = 128 * 1024; // above this, pass code via a file instead of argv (ARG_MAX guard)
|
|
46
46
|
|
|
47
|
+
// Python cell runner (P1: rich mime-typed results, Jupyter/E2B-style, no Jupyter kernel). Runs INSIDE
|
|
48
|
+
// the box (it is Python, regardless of which binding drove it): execs the user cell, then captures the
|
|
49
|
+
// trailing bare expression's value, every display(obj) call, and every open matplotlib figure, writing
|
|
50
|
+
// them as a JSON mime-bundle list the binding reads back. stdout/stderr/exit are UNTOUCHED. Kept byte-
|
|
51
|
+
// identical to the Python binding's runner. __KERN_CELL__/__KERN_RES__ are substituted per call.
|
|
52
|
+
const PY_RUNNER = `
|
|
53
|
+
import sys, os, json, base64, io, ast, traceback, builtins
|
|
54
|
+
_CELL = "__KERN_CELL__"
|
|
55
|
+
_RES = "__KERN_RES__"
|
|
56
|
+
_out = []
|
|
57
|
+
def _bundle(o):
|
|
58
|
+
d = {}
|
|
59
|
+
for meth, key in (("_repr_html_", "text/html"), ("_repr_markdown_", "text/markdown"),
|
|
60
|
+
("_repr_svg_", "image/svg+xml"), ("_repr_latex_", "text/latex")):
|
|
61
|
+
try:
|
|
62
|
+
fn = getattr(o, meth, None)
|
|
63
|
+
if callable(fn):
|
|
64
|
+
v = fn()
|
|
65
|
+
if isinstance(v, str) and v:
|
|
66
|
+
d[key] = v
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
try:
|
|
70
|
+
fn = getattr(o, "_repr_json_", None)
|
|
71
|
+
if callable(fn):
|
|
72
|
+
v = fn()
|
|
73
|
+
if v is not None:
|
|
74
|
+
d["application/json"] = v if isinstance(v, str) else json.dumps(v)
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
for meth, key in (("_repr_png_", "image/png"), ("_repr_jpeg_", "image/jpeg")):
|
|
78
|
+
try:
|
|
79
|
+
fn = getattr(o, meth, None)
|
|
80
|
+
if callable(fn):
|
|
81
|
+
v = fn()
|
|
82
|
+
if v:
|
|
83
|
+
raw = v if isinstance(v, (bytes, bytearray)) else str(v).encode()
|
|
84
|
+
d[key] = base64.b64encode(raw).decode()
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
if "text/plain" not in d:
|
|
88
|
+
try:
|
|
89
|
+
d["text/plain"] = repr(o)
|
|
90
|
+
except Exception:
|
|
91
|
+
d["text/plain"] = "<unrepresentable>"
|
|
92
|
+
return d
|
|
93
|
+
def display(o=None, **kw):
|
|
94
|
+
if o is not None:
|
|
95
|
+
_out.append(_bundle(o))
|
|
96
|
+
builtins.display = display
|
|
97
|
+
sys.argv = [_CELL]
|
|
98
|
+
_g = {"__name__": "__main__", "__file__": _CELL, "display": display}
|
|
99
|
+
_rc = 0
|
|
100
|
+
try:
|
|
101
|
+
_src = open(_CELL, "r", encoding="utf-8").read()
|
|
102
|
+
_tree = ast.parse(_src, _CELL, "exec")
|
|
103
|
+
_tail = None
|
|
104
|
+
if _tree.body and isinstance(_tree.body[-1], ast.Expr):
|
|
105
|
+
_tail = _tree.body.pop().value
|
|
106
|
+
exec(compile(_tree, _CELL, "exec"), _g)
|
|
107
|
+
if _tail is not None:
|
|
108
|
+
_val = eval(compile(ast.Expression(_tail), _CELL, "eval"), _g)
|
|
109
|
+
if _val is not None:
|
|
110
|
+
_out.append(_bundle(_val))
|
|
111
|
+
except SystemExit as _e:
|
|
112
|
+
_rc = _e.code if isinstance(_e.code, int) else (0 if _e.code is None else 1)
|
|
113
|
+
except BaseException as _e:
|
|
114
|
+
_tb = _e.__traceback__
|
|
115
|
+
while _tb is not None and _tb.tb_frame.f_code.co_filename != _CELL:
|
|
116
|
+
_tb = _tb.tb_next
|
|
117
|
+
sys.stderr.write("".join(traceback.format_exception(type(_e), _e, _tb)))
|
|
118
|
+
_rc = 1
|
|
119
|
+
try:
|
|
120
|
+
if "matplotlib.pyplot" in sys.modules:
|
|
121
|
+
_plt = sys.modules["matplotlib.pyplot"]
|
|
122
|
+
for _n in _plt.get_fignums():
|
|
123
|
+
_buf = io.BytesIO()
|
|
124
|
+
_plt.figure(_n).savefig(_buf, format="png")
|
|
125
|
+
_out.append({"image/png": base64.b64encode(_buf.getvalue()).decode()})
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
try:
|
|
129
|
+
with open(_RES, "w", encoding="utf-8") as _fh:
|
|
130
|
+
json.dump(_out, _fh)
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
sys.exit(_rc)
|
|
134
|
+
`;
|
|
135
|
+
|
|
47
136
|
// Signal-derived exit codes (128 + signum) we classify.
|
|
48
137
|
const EXIT_SIGKILL = 137; // SIGKILL: timeout backstop or OOM (indistinguishable without cgroup)
|
|
49
138
|
const EXIT_SIGSYS = 159; // SIGSYS: a seccomp-denied syscall = a blocked escape attempt
|
|
@@ -80,10 +169,47 @@ class MountRefused extends SandboxError {
|
|
|
80
169
|
}
|
|
81
170
|
}
|
|
82
171
|
|
|
172
|
+
/** A rich, mime-typed value captured from a Python `runCode` (the way a Jupyter/E2B cell captures
|
|
173
|
+
* output): the value of the code's last bare expression, every `display(obj)` call, and every open
|
|
174
|
+
* matplotlib figure. `data` maps a MIME type to its payload; text/* and application/json are strings,
|
|
175
|
+
* image/* are base64 strings (use `.png`/`.jpeg` for Buffers). One value can carry several forms. */
|
|
176
|
+
class Result {
|
|
177
|
+
constructor(data) {
|
|
178
|
+
this.data = data || {};
|
|
179
|
+
}
|
|
180
|
+
get text() {
|
|
181
|
+
return this.data["text/plain"];
|
|
182
|
+
}
|
|
183
|
+
get html() {
|
|
184
|
+
return this.data["text/html"];
|
|
185
|
+
}
|
|
186
|
+
get markdown() {
|
|
187
|
+
return this.data["text/markdown"];
|
|
188
|
+
}
|
|
189
|
+
get svg() {
|
|
190
|
+
return this.data["image/svg+xml"];
|
|
191
|
+
}
|
|
192
|
+
get json() {
|
|
193
|
+
return this.data["application/json"];
|
|
194
|
+
}
|
|
195
|
+
get png() {
|
|
196
|
+
const v = this.data["image/png"];
|
|
197
|
+
return v ? Buffer.from(v, "base64") : null;
|
|
198
|
+
}
|
|
199
|
+
get jpeg() {
|
|
200
|
+
const v = this.data["image/jpeg"];
|
|
201
|
+
return v ? Buffer.from(v, "base64") : null;
|
|
202
|
+
}
|
|
203
|
+
/** The MIME types this value was captured as. */
|
|
204
|
+
formats() {
|
|
205
|
+
return Object.keys(this.data);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
83
209
|
/** The outcome of one runCode()/run(). `fault` is the source of truth for "did the SANDBOX act";
|
|
84
210
|
* `exitCode`/`stdout` are what the user's code did. `success` requires both clean. */
|
|
85
211
|
class ExecutionResult {
|
|
86
|
-
constructor({ stdout, stderr, exitCode, durationMs, fault, files, truncated }) {
|
|
212
|
+
constructor({ stdout, stderr, exitCode, durationMs, fault, files, truncated, results }) {
|
|
87
213
|
this.stdout = stdout;
|
|
88
214
|
this.stderr = stderr;
|
|
89
215
|
this.exitCode = exitCode;
|
|
@@ -92,6 +218,8 @@ class ExecutionResult {
|
|
|
92
218
|
this.fault = fault || null;
|
|
93
219
|
this.files = files || [];
|
|
94
220
|
this.truncated = !!truncated;
|
|
221
|
+
/** @type {Result[]} rich mime-typed values (Python runCode) */
|
|
222
|
+
this.results = results || [];
|
|
95
223
|
}
|
|
96
224
|
/** True iff the code exited 0 AND no sandbox fault fired. */
|
|
97
225
|
get success() {
|
|
@@ -918,6 +1046,7 @@ class Sandbox {
|
|
|
918
1046
|
if (!spec)
|
|
919
1047
|
throw new SandboxError(`unsupported language ${JSON.stringify(language)} (v1: 'python' | 'bash' | 'node')`);
|
|
920
1048
|
const [runner, evalFlag, ext] = spec;
|
|
1049
|
+
if (language === "python") return this._runPythonCell(code);
|
|
921
1050
|
let command;
|
|
922
1051
|
if (Buffer.byteLength(code, "utf8") > INLINE_CODE_MAX) {
|
|
923
1052
|
const cell = `.cell-${crypto.randomBytes(4).toString("hex")}.${ext}`;
|
|
@@ -929,6 +1058,44 @@ class Sandbox {
|
|
|
929
1058
|
return this._spawn(command, { network: this.network, timeoutS: this.timeoutS });
|
|
930
1059
|
}
|
|
931
1060
|
|
|
1061
|
+
/** Run Python through the cell runner so a trailing expression, display() calls and matplotlib
|
|
1062
|
+
* figures are captured as rich mime-typed `result.results` (Jupyter/E2B-style). stdout/stderr/exit
|
|
1063
|
+
* are identical to a plain run; capture is best-effort. Internal cell/runner/results files are
|
|
1064
|
+
* removed and hidden from `result.files`. */
|
|
1065
|
+
async _runPythonCell(code) {
|
|
1066
|
+
const uid = crypto.randomBytes(4).toString("hex");
|
|
1067
|
+
const cell = `.cell-${uid}.py`;
|
|
1068
|
+
const resf = `.res-${uid}.json`;
|
|
1069
|
+
const runf = `.run-${uid}.py`;
|
|
1070
|
+
await this.writeFile(cell, code);
|
|
1071
|
+
const shim = PY_RUNNER.replace("__KERN_CELL__", `${WORKSPACE}/${cell}`).replace(
|
|
1072
|
+
"__KERN_RES__",
|
|
1073
|
+
`${WORKSPACE}/${resf}`,
|
|
1074
|
+
);
|
|
1075
|
+
await this.writeFile(runf, shim);
|
|
1076
|
+
const result = await this._spawn(["python3", `${WORKSPACE}/${runf}`], {
|
|
1077
|
+
network: this.network,
|
|
1078
|
+
timeoutS: this.timeoutS,
|
|
1079
|
+
});
|
|
1080
|
+
try {
|
|
1081
|
+
const parsed = JSON.parse(await this.readFile(resf));
|
|
1082
|
+
if (Array.isArray(parsed))
|
|
1083
|
+
result.results = parsed.filter((r) => r && typeof r === "object").map((r) => new Result(r));
|
|
1084
|
+
} catch {
|
|
1085
|
+
/* no results file / unreadable / bad JSON: leave results empty, run otherwise intact */
|
|
1086
|
+
}
|
|
1087
|
+
const internal = new Set([cell, resf, runf]);
|
|
1088
|
+
for (const name of internal) {
|
|
1089
|
+
try {
|
|
1090
|
+
fs.unlinkSync(path.join(this._ws, name));
|
|
1091
|
+
} catch {
|
|
1092
|
+
/* ignore */
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
result.files = result.files.filter((fi) => !internal.has(fi.path));
|
|
1096
|
+
return result;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
932
1099
|
/** Run an arbitrary `command` (an argv ARRAY, never a shell string) in a fresh box. */
|
|
933
1100
|
async run(command) {
|
|
934
1101
|
this._requireEntered();
|
|
@@ -968,6 +1135,7 @@ module.exports = {
|
|
|
968
1135
|
withSandbox,
|
|
969
1136
|
runCode,
|
|
970
1137
|
ExecutionResult,
|
|
1138
|
+
Result,
|
|
971
1139
|
SandboxError,
|
|
972
1140
|
MountRefused,
|
|
973
1141
|
version: VERSION,
|
package/package.json
CHANGED