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.
Files changed (4) hide show
  1. package/README.md +17 -7
  2. package/index.d.ts +25 -0
  3. package/index.js +170 -2
  4. 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
- **Charts / artifacts (the "code interpreter" pattern).** No Jupyter kernel: have the code WRITE the
140
- artifact to the workspace, then read it back with `readFile`.
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]); plt.savefig('chart.png')");
146
- const png = await sbx.readFile("chart.png"); // Buffer, ready to return to the model / user
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.2";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kern-sandbox",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Run LLM/agent-generated code in a fast, local, daemonless kernel sandbox. A thin, dependency-free wrapper around the kern binary.",
5
5
  "keywords": [
6
6
  "sandbox",