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.
- package/README.md +90 -3
- package/index.d.ts +72 -1
- package/index.js +921 -23
- 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
|
|
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
|
-
|
|
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;
|