kern-sandbox 0.1.35 → 0.1.37

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 +90 -3
  2. package/index.d.ts +72 -1
  3. package/index.js +921 -23
  4. package/package.json +1 -2
package/README.md CHANGED
@@ -28,7 +28,11 @@ const r = await kern.runCode("print(sum(range(100)))");
28
28
  console.log(r.stdout, r.success); // "4950\n" true
29
29
  ```
30
30
 
31
- TypeScript types ship in the box:
31
+ TypeScript types ship in the box. `Buffer` appears in the public surface **because we typed it that
32
+ way**, so a TypeScript consumer also needs `@types/node`; without it `tsc` reports `Cannot find name
33
+ 'Buffer'` against this package's `.d.ts` and tells you what to install. Typing that surface as
34
+ `Uint8Array` would remove the requirement (a `Buffer` is one), and that is a change to the published
35
+ surface rather than a fix, so it is not in this release.
32
36
 
33
37
  ```ts
34
38
  import { runCode, withSandbox, Sandbox } from "kern-sandbox";
@@ -93,7 +97,10 @@ const r = await kern.runCode("console.log([1,2,3].map(x => x * x))", {
93
97
  });
94
98
  ```
95
99
 
96
- `language` is `"python"` (default), `"bash"`, or `"node"`. Match the image to the language.
100
+ `language` is `"python"` (default), `"bash"`, `"sh"` or `"node"`. Match the image to the language:
101
+ **`bash` runs bash and `sh` runs the POSIX shell**, which are different languages (`[[ ]]`, arrays and
102
+ `pipefail` are bash), and alpine carries no bash at all. Asking for one the image lacks returns an
103
+ `exec_failed` fault naming it, never a different shell.
97
104
 
98
105
  ## The result
99
106
 
@@ -178,6 +185,7 @@ new Sandbox({
178
185
  // user namespace. Pass [] to keep them (needed only if the workload binds a
179
186
  // port below 1024 INSIDE the box).
180
187
  mounts, // { hostSrc: boxTarget } or { src: [target, "ro"] }
188
+ tmpfs, // omitted -> 64 MiB of scratch at /tmp; {} -> none; { "/tmp": "512m" } to resize
181
189
  profiles, // reusable kern.toml profiles: ["vcpu:heavy", "vgpio:leds", "vdisk:scratch"]
182
190
  env, // { KEY: "value" }
183
191
  maxOutputBytes, // default 64 MiB
@@ -187,19 +195,98 @@ new Sandbox({
187
195
  // apparmor=), an LSM layer over seccomp; kern fails the box CLOSED if it isn't loaded.
188
196
  requireLimits, // default false; true = FAIL-CLOSED (refuse to start unless caps enforced). NOT
189
197
  // enforceLimits (that picks the cap PATH); mutually exclusive with KERN_ALLOW_UNCAPPED env.
190
- depsReadonly, // default false
198
+ depsReadonly, // default TRUE: runCode cannot modify what setup= installed
191
199
  trackFiles, // default true: diff the workspace each call for result.files (O(files)); false = [], O(1)
192
200
  onStdout, // (chunk: Buffer) => void, live stdout streaming (result.stdout still captured)
193
201
  onStderr, // (chunk: Buffer) => void, live stderr streaming
194
202
  });
195
203
  ```
196
204
 
205
+ **Writable paths: `/workspace`, `/tmp` and `/dev/shm`.** The box root is read-only, so `/tmp` is a
206
+ 64 MiB tmpfs this binding mounts for you. Without it a write naming `/tmp` fails with `EROFS` and
207
+ temp-file helpers fall back to the current directory, quietly putting scratch into your persistent
208
+ workspace where `listFiles` then reports it. The bytes are charged to the box's own memory cgroup, so
209
+ filling `/tmp` OOM-kills the box and never fills the host disk. Resize with `tmpfs: { "/tmp": "512m" }`,
210
+ remove with `tmpfs: {}`, or bind your own directory at `/tmp` through `mounts` and the default steps
211
+ aside (a `:ro` bind included, which leaves `/tmp` read-only: your call, not an accident). **The unit is
212
+ required and the target may not contain a `:`.** kern's CLI takes both spellings and means the
213
+ opposite of what you do: a bare `"64"` is 64 BYTES, `"0"` is UNLIMITED, and `["/scratch:9g"]` mounts
214
+ `/scratch` at 9 GiB rather than a directory by that name. All three measured, all three refused here. A size larger than `memoryMb` is refused for the same family
215
+ of reason: `df` would report it to a program that preflights. The binding's own default is clamped to
216
+ half the cap instead.
217
+
218
+ **`memoryMb` bounds the cgroup, not the workload's usable memory.** The cap is shared with
219
+ memory-backed filesystems in the same box, and `/dev/shm` is one of them with **no size at all** (the
220
+ kernel's tmpfs default, half of host RAM, so it scales with the machine and not with your config).
221
+ Measured: 200 MiB written there under `memoryMb: 128` OOM-kills the box whatever `/tmp` is set to.
222
+ `tmpfs: { "/dev/shm": ... }` is refused by kern; `mounts` at the same target IS accepted and stacks over
223
+ kern's own mount; measured through it, `multiprocessing.shared_memory` and POSIX semaphores still
224
+ work. Two costs: a plain directory is unbounded on DISK instead of in RAM, so bounding it means
225
+ binding a host directory that is itself a sized tmpfs, and it has no tmpfs lifetime, so what the box
226
+ writes to `/dev/shm` is still on the host after the box dies.
227
+
228
+ **Scratch does not survive a call.** Each `runCode` is a fresh box, so `/tmp` is fresh too while the
229
+ workspace persists. Put anything a later call must find in the workspace. The `setup` box is the exception: an install needs unbounded scratch, so the default is not
230
+ applied there (an explicit `tmpfs` still is).
231
+
232
+ **Toolchains in the box** need two writable places, and the error names neither. Go reports `failed to
233
+ initialize build cache at /root/.cache`, which says nothing about `HOME`; npm renders a failed
234
+ `mkdir /root/.npm` as `Invalid response body while trying to fetch https://registry.npmjs.org/...`,
235
+ which reads as a network fault and is not one. Measured on `node:22`: neither -> exit 2, `HOME` alone
236
+ with a read-only `/tmp` -> still exit 2, both -> exit 0. Pass both:
237
+
238
+ ```js
239
+ new Sandbox({
240
+ image: "golang:1.23-alpine",
241
+ env: { HOME: "/workspace" }, // npm's ~/.npm, Go's ~/.cache, Rust's CARGO_HOME, .NET's NuGet
242
+ tmpfs: { "/tmp": "512m" }, // scratch; 64 MiB fits a small install, a real one needs more
243
+ });
244
+ ```
245
+
197
246
  `runCode`/`run` also take `timeoutS`/`onStdout`/`onStderr` as **per-call** options that override the
198
247
  session defaults for that one call. A `vcpu:` profile can carry `cpus`+`memory`; `memoryMb`/`cpus` are
199
248
  explicit flags that **override** a profile's values (and the `memoryMb` default `512` shadows a profile's
200
249
  `memory`, so pass `memoryMb: null` to let the profile apply). The **MCP server** (`kern-mcp`, for Claude
201
250
  Desktop / Cursor) ships in the Python package `kern-sandbox` (`pip install kern-sandbox`).
202
251
 
252
+ ## Prewarming: a box ready before the call arrives
253
+
254
+ `prewarm: N` keeps N boxes started in advance, each holding a booted interpreter that has run nothing,
255
+ so a `runCode` claims one instead of paying for a box start plus an interpreter boot. Measured on
256
+ `python:3.12-slim`, six calls each: **14.2 ms p50 by default against 0.8 ms with `prewarm: 4`**, and
257
+ 30.9 ms against 0.9 for the first call.
258
+
259
+ The refill runs while your agent thinks, so it is off the caller's clock. That also says when it buys
260
+ nothing: if calls arrive faster than the pool refills, the pool empties and you are back to the
261
+ default cost. N is the burst you want covered, not a throughput knob.
262
+
263
+ Each prewarmed box serves ONE call and is discarded, so the isolation is unchanged: a fresh box per
264
+ call, network off, the same caps. Only the moment of creation moves. That is the difference from
265
+ `kernel()`, which deliberately shares one process across cells.
266
+
267
+ ```js
268
+ await withSandbox({ image: "python:3.12-slim", prewarm: 4 }, async (sbx) => {
269
+ const r = await sbx.runCode("print(1)"); // served from the pool
270
+ });
271
+ ```
272
+
273
+ The pool key includes the image, the caps and the profiles, so a session with different settings never
274
+ receives a box built for another one.
275
+
276
+ ## Run pi's coding tools in a box
277
+
278
+ [`integrations/pi`](https://github.com/getkern/kern/tree/main/integrations/pi) is an extension for
279
+ [pi](https://github.com/earendil-works/pi) built on THIS binding: it routes pi's built-in `bash`,
280
+ `read`, `write`, `edit`, `ls`, `grep` and `find` tools into a kern box. The working directory is
281
+ mounted at `/workspace`, so edits write through to the host and everything else a command touches dies
282
+ with the box. pi's default posture is no sandbox: it runs as the user who launched it.
283
+
284
+ The two halves are not confined by the same thing, and the extension's README says which is which:
285
+ `bash` runs INSIDE the box (namespaces, seccomp allowlist, cgroup caps), while `read` and the staging
286
+ half of `write` are host filesystem calls guarded by this binding's `O_NOFOLLOW` and its
287
+ `/proc/self/fd` containment check. Needs Linux, the `kern` binary, and **Node 22 or newer**: pi's own
288
+ package manager imports `globSync` from `node:fs`, which landed in 22.
289
+
203
290
  ## Charts, rich results, live output, and checkpoints
204
291
 
205
292
  **Rich results (the "code interpreter" pattern).** `runCode` runs Python by default, and like a
package/index.d.ts CHANGED
@@ -1,5 +1,20 @@
1
1
  // Type definitions for kern-sandbox
2
2
  // Run LLM/agent-generated code in a fast, local, daemonless kernel sandbox.
3
+ //
4
+ // `Buffer` is in this file's public surface (chunk callbacks, readFile, decoded images), so a
5
+ // TypeScript consumer needs `@types/node`. Deliberately NOT declared with a
6
+ // `/// <reference types="node" />`: that was tried and measured, and it made things worse. Without
7
+ // Node's types the reference adds `TS2688: Cannot find type definition file for 'node'` on TOP of the
8
+ // six `Buffer` errors, and those six already carry TypeScript's own remedy, verbatim: "Do you need to
9
+ // install type definitions for node? Try `npm i --save-dev @types/node`". Seven errors that name the
10
+ // fix are not better than six that name it. The README says it in prose instead.
11
+ //
12
+ // There is a THIRD option, and it is a decision rather than a fix, so it is written down and not
13
+ // taken on a delivery day: type this surface as `Uint8Array` instead of `Buffer`. `Buffer extends
14
+ // Uint8Array`, so a caller's Buffers still satisfy it, the package stops needing `@types/node` at
15
+ // all, and it becomes typable outside Node. That is a change to the published surface, which is a
16
+ // 0.2 conversation. Until then the requirement is a consequence of a type choice we made, not a
17
+ // law of the platform, and the README should not imply otherwise.
3
18
 
4
19
  /** What stopped the code at the SANDBOX level. Reported as data on a result, never thrown. */
5
20
  export type SandboxFaultType = "timeout" | "oom" | "escape_blocked" | "killed" | "startup_failed" | "exec_failed";
@@ -93,6 +108,31 @@ export interface SandboxOptions {
93
108
  egressAllow?: string[];
94
109
  /** Extra host->box binds: { hostSrc: boxTarget } or { src: [target, "ro"] }. Sensitive sources refused. */
95
110
  mounts?: Record<string, MountSpec>;
111
+ /**
112
+ * Fresh in-box scratch filesystems (kern `--tmpfs`), as `{ "/path": "64m" }` or `["/path"]`.
113
+ *
114
+ * **A 64 MiB tmpfs is mounted at `/tmp` by default.** The box root is read-only, so without it a
115
+ * write naming `/tmp` fails and temp-file helpers fall back to the current directory, quietly
116
+ * putting scratch into your persistent workspace. Pass `{ "/tmp": "512m" }` to resize, `{}` for
117
+ * none, or bind your own directory at `/tmp` through `mounts` and the default steps aside. The
118
+ * bytes are charged to the box's memory cgroup, so a runaway writer is OOM-killed rather than
119
+ * filling the host disk.
120
+ *
121
+ * **Scratch does not survive a command, EXCEPT in a kernel().** Each `runCode`/`run` is a fresh
122
+ * box, so the tmpfs is fresh too, while the workspace persists. A `kernel()` is one long-lived box,
123
+ * so its `/tmp` persists across cells and the size is CUMULATIVE: measured at 10 MiB per step under
124
+ * the 64 MiB default, ten `runCode` calls all pass while the same ten cells in a kernel fail from
125
+ * the seventh with ENOSPC.
126
+ *
127
+ * A read-only `/tmp` used to fail LOUDLY at the moment of the mistake; now a tool that writes state
128
+ * to the workspace and a lock to `/tmp` writes both, and the next call finds workspace state
129
+ * pointing at a `/tmp` path that is gone. Put anything another call has to find in the workspace.
130
+ *
131
+ * The EFFECTIVE ceiling is `min(size, memoryMb)`, and `df` inside the box
132
+ * does not know that: a `"1t"` scratch shows 1.0T free and the first write past the cap is an OOM,
133
+ * not `ENOSPC`. The `oom` fault names the scratch so the reader is not sent to their allocation.
134
+ */
135
+ tmpfs?: Record<string, string | null> | string[];
96
136
  /**
97
137
  * kern resource profiles to attach, e.g. ["vcpu:heavy", "vgpio:leds", "vdisk:scratch"]. Each names a
98
138
  * [[vcpu]]/[[vgpio]]/[[vdisk]] block in your ~/.config/kern/kern.toml: a CPU+memory slice, a specific
@@ -116,9 +156,24 @@ export interface SandboxOptions {
116
156
  onStdout?: (chunk: Buffer) => void;
117
157
  /** Called with each stderr Buffer chunk as it arrives. */
118
158
  onStderr?: (chunk: Buffer) => void;
159
+ /** Keep N boxes started in advance, each holding a booted interpreter that has run nothing, so a
160
+ * python `runCode` claims one instead of starting its own: ~41 ms per call becomes ~2 ms.
161
+ *
162
+ * It does NOT change what a call gets. Each prewarmed box serves exactly one cell and is then
163
+ * destroyed, so the cell still runs in a private box that has executed nothing else; what moves is
164
+ * when the box and interpreter started. A call that streams (`onStdout`/`onStderr`), asks for a
165
+ * non-python language, or differs in posture from the pooled box takes the ordinary path.
166
+ *
167
+ * Default 0: N warm boxes hold N booted interpreters for the life of the session whether or not a
168
+ * call arrives, which is a resource decision the caller owns. A slot refills in ~70 ms, so N is a
169
+ * burst budget - N back-to-back calls run warm and the rest fall back until the pool catches up. */
170
+ prewarm?: number;
119
171
  }
120
172
 
121
- export type Language = "python" | "bash" | "node";
173
+ /** `bash` runs bash and `sh` runs the POSIX shell: they are different shells, and the image must
174
+ * provide the one you ask for. On alpine there is no bash, and asking for it yields an
175
+ * `exec_failed` fault naming it rather than a shell you did not choose. */
176
+ export type Language = "python" | "bash" | "sh" | "node";
122
177
 
123
178
  /** Per-call overrides for runCode()/run(): each defaults to the Sandbox's constructor value; an explicit
124
179
  * value applies to this call only (a `null` callback disables streaming for the call). */
@@ -148,6 +203,15 @@ export class Sandbox {
148
203
  /** Write data to a workspace-relative path (host-direct, O_NOFOLLOW on the final component). */
149
204
  writeFile(path: string, data: Buffer | string): Promise<void>;
150
205
  /** Read a workspace-relative path (host-direct, O_NOFOLLOW). */
206
+ /**
207
+ * Read a workspace file, host-direct, every path component opened `O_NOFOLLOW`.
208
+ *
209
+ * `maxBytes` is a **REFUSAL threshold, not a partial read**: a larger file throws and nothing is
210
+ * returned, it never yields the first `maxBytes` bytes. Safer for a boundary (a silent truncation is
211
+ * how a caller ends up parsing half a file) and not what the name suggests, which is why it is
212
+ * spelled out: asking for 16 bytes to sniff a magic number and wrapping the call in `catch` turns
213
+ * every image in a project into "not an image", in silence.
214
+ */
151
215
  readFile(path: string, opts?: { maxBytes?: number }): Promise<Buffer>;
152
216
  /** Write a gzip tar of the whole workspace to `dest`, a portable filesystem checkpoint (NOT memory). */
153
217
  snapshot(dest: string): void;
@@ -184,3 +248,10 @@ export function withSandbox<T>(opts: SandboxOptions, fn: (sandbox: Sandbox) => P
184
248
  export function runCode(code: string, opts?: SandboxOptions & { language?: Language }): Promise<ExecutionResult>;
185
249
 
186
250
  export const version: string;
251
+
252
+ /** The size, in MiB, of the tmpfs this binding mounts at `/tmp` by default.
253
+ *
254
+ * Exported so a consumer that wants a different default can express it as a multiple of this one
255
+ * rather than declaring a second independent number that drifts from it.
256
+ */
257
+ export const DEFAULT_TMPFS_MB: number;