@zenera/sandbox 0.0.0-stage → 1.1.57

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrey Ryabov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,61 @@
1
- # Temporary Holding Version
1
+ # @zenera/sandbox
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Where an agent's commands run, and the files those commands see. One
4
+ `Sandbox` per session, shared by every agent in it. Depends on Node alone, so a
5
+ host, a sandbox service and the agent inside a container can all build on the
6
+ same contract.
7
+
8
+ ```ts
9
+ import { podmanSandbox } from '@zenera/sandbox';
10
+
11
+ const sandbox = podmanSandbox({ root: '/path/to/workspace', key: 'session-1' });
12
+ await sandbox.exec('python -V');
13
+ const view = sandbox.files({ root: '/path/to/workspace', mount: '/workspace' });
14
+ await view.fs!.writeFile(`${view.root}/notes.md`, 'hello');
15
+ await sandbox.close();
16
+ ```
17
+
18
+ ## The contract
19
+
20
+ - **`Sandbox`** - `prepare` (pull the image, or build it from `build:`),
21
+ `exec`, background jobs (`startJob`, `readJob`, `stopJob`),
22
+ `close` at the end of a turn, and the files: `fs` (an `FsDriver`) and
23
+ `files(view)`, which maps the host's workspace and mounts to the paths `fs`
24
+ finds them at. The invariant: **a file written through `fs` is the file
25
+ `exec` sees under the same name, and the reverse.**
26
+ - **`SandboxFactory`** - `(opts: SandboxOptions) => Sandbox`, how a host picks
27
+ where sandboxes run.
28
+ - **`FsDriver`** - the filesystem seam, usable without a sandbox. `localFs` is
29
+ this machine's disk; `InMemoryFs` keeps a tree in memory.
30
+
31
+ ## The image
32
+
33
+ A spec names an `image` to pull, or a `build` (`{ dockerfile, context, args }`)
34
+ to build. Getting it there is the sandbox's job, in `prepare()`: podman builds
35
+ on this machine, a sandbox service on its own builders. What every backend
36
+ shares is the build's identity, `buildIdentity(build)` - the context's files
37
+ and the hash of them - so a project names the same image everywhere.
38
+ `prepare({ onProgress, slow, force, allowBuild: false })` reports progress,
39
+ rebuilds, or refuses to build at all; `start` does whatever of it is still
40
+ needed. A failed build is an `ImageBuildError` (`isImageBuildError` checks it
41
+ by name, across copies of this package).
42
+
43
+ ## Implementations
44
+
45
+ - **`PodmanSandbox`** / `podmanSandbox` - a podman container on this machine,
46
+ binding host directories, with `fs = localFs`. `ensurePodmanImage` is its
47
+ image step on its own.
48
+ - A sandbox that keeps its files with the container (a sandbox service) serves
49
+ `fs` itself and returns `inSandboxPaths(view, fs)` from `files()`.
50
+
51
+ ## Testing an implementation
52
+
53
+ `@zenera/sandbox/testing` exports the contract as vitest suites, the same ones
54
+ this package runs against its own implementations:
55
+
56
+ ```ts
57
+ import { describeFsDriver, describeSandbox } from '@zenera/sandbox/testing';
58
+
59
+ describeFsDriver('remote fs', () => ({ fs: new RemoteFs(url), root: '/workspace' }));
60
+ describeSandbox('remote', () => ({ sandbox: openRemote(opts), root: opts.root }));
61
+ ```
@@ -0,0 +1,230 @@
1
+ import type { FsDriver } from './fs.ts';
2
+ import type { FilesView, Mount } from './mounts.ts';
3
+ /** The image a project gets when it does not name one. */
4
+ export declare const DEFAULT_SANDBOX_IMAGE = "docker.io/library/python:3.14-slim-bookworm";
5
+ /**
6
+ * Everything about a container that an author can state: the project's
7
+ * `sandbox:` block, resolved. The identity of a container here *is* its
8
+ * configuration, so a persisted one is reused only while the spec is equal.
9
+ */
10
+ export interface SandboxSpec {
11
+ /** the base image commands run in */
12
+ image?: string;
13
+ /**
14
+ * A Dockerfile to build instead of an image to pull. The sandbox builds it
15
+ * where it runs — podman on this machine, a service on its own builders —
16
+ * under a tag derived from its content; `image` is then ignored.
17
+ */
18
+ build?: BuildSpec;
19
+ /** fractional cores, as podman's `--cpus` */
20
+ cpus?: number;
21
+ /** MiB, as podman's `--memory` */
22
+ memory?: number;
23
+ network?: SandboxNetwork;
24
+ /** where the workspace is mounted, and the default cwd */
25
+ workdir?: string;
26
+ /** default seconds a single command may take */
27
+ timeout?: number;
28
+ /** how much of the host the container is allowed to be; `standard` by default */
29
+ hardening?: SandboxHardening;
30
+ /** uid, name, or `uid:gid`; unset means the image's own user */
31
+ user?: string;
32
+ /** keep the container between sessions rather than removing it */
33
+ persist?: boolean;
34
+ /** environment inside the container — resolved values, never a passthrough */
35
+ env?: Record<string, string>;
36
+ /**
37
+ * Variables forwarded from this process by *name*, for credentials.
38
+ *
39
+ * Separate from `env` for two reasons, both about the value never being
40
+ * written down. `--env NAME` without an `=` tells podman to read the value
41
+ * out of its own environment, so the secret never appears on a command
42
+ * line, in `ps`, or in `podman inspect`. And the container's identity is
43
+ * computed from the names alone, so rotating a key does not abandon a
44
+ * persisted container's filesystem.
45
+ */
46
+ secrets?: readonly string[];
47
+ }
48
+ export type SandboxNetwork = 'bridge' | 'none' | 'host';
49
+ /** A Dockerfile and what it may see. Paths are absolute and already checked by the host. */
50
+ export interface BuildSpec {
51
+ dockerfile: string;
52
+ /** the build context: what `COPY` can reach */
53
+ context: string;
54
+ /** `--build-arg`s; part of the image's identity */
55
+ args?: Record<string, string>;
56
+ }
57
+ /** How a sandbox gets its image ready. */
58
+ export interface PrepareOptions {
59
+ /** lines worth showing a person: what is being pulled or built, and how it ended */
60
+ onProgress?(line: string): void;
61
+ /** wraps a slow step — a pull, a build — so a host can show it is still going */
62
+ slow?<T>(label: string, work: Promise<T>): Promise<T>;
63
+ /** pull or build again even when the image is there; `zen sandbox pull` */
64
+ force?: boolean;
65
+ /**
66
+ * false: an image that would have to be built is a `SandboxError` instead —
67
+ * for a host whose images are built somewhere else, ahead of time.
68
+ */
69
+ allowBuild?: boolean;
70
+ signal?: AbortSignal;
71
+ }
72
+ /**
73
+ * Two postures, because they cannot both be the default.
74
+ *
75
+ * `standard` is the one this tool was built around: a container with an init,
76
+ * a pid ceiling and `no-new-privileges`, running an image the agent can still
77
+ * install into. Installing a package is the ordinary use of a shell, so the
78
+ * capabilities stay and the root filesystem stays writable.
79
+ *
80
+ * `strict` is for running code nobody has read. Every capability is dropped,
81
+ * the root filesystem is read-only, the only writable temporary space is an
82
+ * in-memory `/tmp` with `noexec,nosuid,nodev`, the process runs as the host's
83
+ * own unprivileged uid rather than as container root, and the network is off
84
+ * unless the project turns it back on. What that costs is exactly what it
85
+ * removes: `apt`, `pip` and anything else that writes outside the mounts stop
86
+ * working, so a strict project bakes its dependencies into the image instead.
87
+ *
88
+ * What it does *not* do is make the bind mounts go away. `/workspace` is the
89
+ * work itself and stays writable — the profile shrinks the blast radius of a
90
+ * command, it does not pretend the agent has nothing to edit.
91
+ */
92
+ export type SandboxHardening = 'standard' | 'strict';
93
+ export interface SandboxOptions extends SandboxSpec {
94
+ /** host directory mounted at `workdir` */
95
+ root: string;
96
+ /**
97
+ * Stable name for whatever owns this sandbox — a session id, typically.
98
+ * It prefixes the container name, so a crashed process leaves something a
99
+ * human can recognise rather than a hash.
100
+ */
101
+ key: string;
102
+ /** refuse to mount the workspace writable */
103
+ readOnly?: boolean;
104
+ /** extra bind mounts; the host side must already exist */
105
+ mounts?: readonly Mount[];
106
+ /**
107
+ * Environment set on every command rather than on the container: values
108
+ * that belong to the run, not to the container — the user it is acting for.
109
+ *
110
+ * `env` is fixed when the container is created, and a persisted container
111
+ * is kept between sessions; a run's values written there
112
+ * would still be answering for whoever came before. Not part of the
113
+ * container's name either, for the same reason in reverse: a new user is
114
+ * not a new machine. Plain values on podman's command line, so nothing
115
+ * secret goes here — that is what `secrets` is for.
116
+ */
117
+ execEnv?: Record<string, string>;
118
+ /**
119
+ * Directories put in front of a path-like variable for every command —
120
+ * `PYTHONPATH`, say — keeping whatever the image already set behind them.
121
+ * `--env` could only replace the image's value, and an image that relies on
122
+ * its own `PYTHONPATH` would break on the first `python`.
123
+ */
124
+ execPaths?: Record<string, readonly string[]>;
125
+ }
126
+ /**
127
+ * One session's sandbox. `PodmanSandbox` is one on this machine; a remote
128
+ * sandbox service is another. The tools and the job model are the same for
129
+ * both.
130
+ */
131
+ export interface Sandbox {
132
+ /**
133
+ * Derived from the spec, so a persisted sandbox is found again by an equal
134
+ * spec, and a host can tell a reused one from a new one.
135
+ */
136
+ readonly name: string;
137
+ /** the files every command sees, under the same names */
138
+ readonly fs: FsDriver;
139
+ /**
140
+ * The file tools' view of this sandbox, in its own paths: given the host's
141
+ * workspace and mounts, where `fs` actually finds each of them.
142
+ */
143
+ files(opts: FilesView): FilesView;
144
+ /** whether anything was actually started — `close` is free when not */
145
+ readonly started: boolean;
146
+ /**
147
+ * Makes the image available — pulled, or built from `build` — without
148
+ * starting anything, so a host can do the slow part before the first turn
149
+ * and show it. Idempotent and single-flight unless `force`; `start` does
150
+ * whatever of it is still needed.
151
+ */
152
+ prepare(opts?: PrepareOptions): Promise<void>;
153
+ /** idempotent and single-flight; every other call starts the sandbox itself */
154
+ start(): Promise<void>;
155
+ exec(command: string, opts?: ExecOptions): Promise<ExecResult>;
156
+ /** starts a command and returns its job id; the output goes to a log */
157
+ startJob(command: string, opts?: ExecOptions): Promise<string>;
158
+ /**
159
+ * A window of a job's log, from line `from` (1-based), plus whether it is
160
+ * still going. An unknown id is a `SandboxError`.
161
+ */
162
+ readJob(id: string, from?: number): Promise<JobOutput>;
163
+ /** Signals the job and everything it started. An unknown id is a `SandboxError`. */
164
+ stopJob(id: string): Promise<JobStopped>;
165
+ /**
166
+ * The session is done for now (the end of a turn): stops a persisted
167
+ * sandbox, removes an ephemeral one, and keeps the files — a sandbox that
168
+ * holds them elsewhere persists them here.
169
+ */
170
+ close(): Promise<void>;
171
+ }
172
+ /** Opens a session's sandbox. Cheap: nothing starts until it is used. */
173
+ export type SandboxFactory = (opts: SandboxOptions) => Sandbox;
174
+ /** A failure the caller can do something about — a missing engine, usually. */
175
+ export declare class SandboxError extends Error {
176
+ readonly hint?: string;
177
+ constructor(message: string, hint?: string);
178
+ }
179
+ export interface ExecOptions {
180
+ cwd?: string;
181
+ /** seconds; the spec's default otherwise */
182
+ timeout?: number;
183
+ signal?: AbortSignal;
184
+ }
185
+ export interface ExecResult {
186
+ exit_code: number;
187
+ stdout: string;
188
+ stderr: string;
189
+ truncated?: boolean;
190
+ timed_out?: boolean;
191
+ duration_ms: number;
192
+ out_of_memory?: OutOfMemory;
193
+ }
194
+ export interface JobOutput {
195
+ job_id: string;
196
+ command: string;
197
+ running: boolean;
198
+ /** once it has ended */
199
+ exit_code?: number;
200
+ duration_ms: number;
201
+ /** the window returned, 1-based and inclusive */
202
+ start_line: number;
203
+ end_line: number;
204
+ /** how long the log is now */
205
+ lines: number;
206
+ /** more of the log follows `end_line` */
207
+ truncated?: boolean;
208
+ output: string;
209
+ out_of_memory?: OutOfMemory;
210
+ }
211
+ export interface JobStopped {
212
+ job_id: string;
213
+ stopped: boolean;
214
+ }
215
+ /**
216
+ * Why a command was killed for memory, which is a different question from
217
+ * whether it was. A container that overran its own `--memory` will do it again
218
+ * however long anyone waits; one the kernel sacrificed for a neighbour may
219
+ * simply succeed later. Exit code 137 is all either of them says.
220
+ */
221
+ export interface OutOfMemory {
222
+ /** what this container was allowed */
223
+ limit_mib: number;
224
+ /** high-water mark for the container's whole life, not for this command */
225
+ peak_mib?: number;
226
+ /** false when the limit itself is the problem, so an unchanged rerun ends the same way */
227
+ retryable: boolean;
228
+ hint: string;
229
+ }
230
+ //# sourceMappingURL=contract.d.ts.map
@@ -0,0 +1,27 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The sandbox
3
+ //
4
+ // The whole environment a session's agents work in: one place commands run —
5
+ // a container, here or on a sandbox service — and the files they see. Every
6
+ // agent of the session shares it. This is the contract every implementation
7
+ // keeps, with one invariant:
8
+ //
9
+ // A file written through `fs` is, at once, the file `exec` sees under the
10
+ // same name — and the reverse.
11
+ //
12
+ // On `zen run` that is free: a podman container binds the host directory the
13
+ // disk driver reads. A sandbox service keeps the files with the container, so
14
+ // it serves `fs` itself.
15
+ // ---------------------------------------------------------------------------
16
+ /** The image a project gets when it does not name one. */
17
+ export const DEFAULT_SANDBOX_IMAGE = 'docker.io/library/python:3.14-slim-bookworm';
18
+ /** A failure the caller can do something about — a missing engine, usually. */
19
+ export class SandboxError extends Error {
20
+ hint;
21
+ constructor(message, hint) {
22
+ super(message);
23
+ this.name = 'SandboxError';
24
+ this.hint = hint;
25
+ }
26
+ }
27
+ //# sourceMappingURL=contract.js.map
package/dist/fs.d.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { type PlatformPath } from 'node:path';
2
+ export interface FsStat {
3
+ size: number;
4
+ isFile(): boolean;
5
+ isDirectory(): boolean;
6
+ isSymbolicLink(): boolean;
7
+ }
8
+ /** One name in a listing: what `stat` says of it, less the size. */
9
+ export type FsEntry = Omit<FsStat, 'size'> & {
10
+ name: string;
11
+ };
12
+ export interface FsDriver {
13
+ /** how this driver's paths are spelled: the host's for `localFs`, posix otherwise */
14
+ readonly path: PlatformPath;
15
+ /**
16
+ * The path with every symlink resolved; throws `ENOENT` when it does not
17
+ * exist. Synchronous because containment is decided synchronously. A
18
+ * driver whose files live elsewhere may answer lexically here, provided
19
+ * the far side refuses an escape on its own.
20
+ */
21
+ realpathSync(path: string): string;
22
+ stat(path: string): Promise<FsStat>;
23
+ readdir(path: string): Promise<FsEntry[]>;
24
+ readFile(path: string): Promise<Buffer>;
25
+ /** at most the first `bytes` bytes, for sniffing a file too large to read whole */
26
+ readHead(path: string, bytes: number): Promise<Buffer>;
27
+ writeFile(path: string, data: string | Uint8Array): Promise<void>;
28
+ /** with parents, and no complaint when it already exists */
29
+ mkdirp(path: string): Promise<void>;
30
+ rm(path: string, opts?: {
31
+ recursive?: boolean;
32
+ force?: boolean;
33
+ }): Promise<void>;
34
+ rename(from: string, to: string): Promise<void>;
35
+ copy(from: string, to: string, opts?: {
36
+ recursive?: boolean;
37
+ }): Promise<void>;
38
+ }
39
+ /** This machine's disk, through `node:fs`. */
40
+ export declare const localFs: FsDriver;
41
+ /**
42
+ * A failure shaped like Node's, for drivers that are not `node:fs`: the file
43
+ * tools read `code` and `path` to say what went wrong in the model's words.
44
+ */
45
+ export declare function fsError(code: string, syscall: string, at: string): NodeJS.ErrnoException;
46
+ //# sourceMappingURL=fs.d.ts.map
package/dist/fs.js ADDED
@@ -0,0 +1,39 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { cp, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import path, {} from 'node:path';
4
+ /** This machine's disk, through `node:fs`. */
5
+ export const localFs = {
6
+ path,
7
+ realpathSync: (p) => realpathSync(p),
8
+ stat: (p) => stat(p),
9
+ readdir: (p) => readdir(p, { withFileTypes: true }),
10
+ readFile: (p) => readFile(p),
11
+ async readHead(p, bytes) {
12
+ const handle = await open(p, 'r');
13
+ try {
14
+ const buf = Buffer.alloc(bytes);
15
+ const { bytesRead } = await handle.read(buf, 0, bytes, 0);
16
+ return buf.subarray(0, bytesRead);
17
+ }
18
+ finally {
19
+ await handle.close();
20
+ }
21
+ },
22
+ writeFile: (p, data) => writeFile(p, data),
23
+ mkdirp: async (p) => void (await mkdir(p, { recursive: true })),
24
+ rm: (p, opts) => rm(p, opts),
25
+ rename: (from, to) => rename(from, to),
26
+ copy: (from, to, opts) => cp(from, to, { recursive: opts?.recursive === true }),
27
+ };
28
+ /**
29
+ * A failure shaped like Node's, for drivers that are not `node:fs`: the file
30
+ * tools read `code` and `path` to say what went wrong in the model's words.
31
+ */
32
+ export function fsError(code, syscall, at) {
33
+ const err = new Error(`${code}: ${syscall} '${at}'`);
34
+ err.code = code;
35
+ err.syscall = syscall;
36
+ err.path = at;
37
+ return err;
38
+ }
39
+ //# sourceMappingURL=fs.js.map
@@ -0,0 +1,62 @@
1
+ import { SandboxError, type BuildSpec, type PrepareOptions } from './contract.ts';
2
+ import { type Runner } from './process.ts';
3
+ /** Where locally built images are tagged. Not scoped: a `/` or an `@` is not a legal tag. */
4
+ export declare const LOCAL_IMAGE_REPO = "localhost/zenera-sandbox";
5
+ /**
6
+ * A build context is meant to be small — a Dockerfile and whatever it copies.
7
+ * Hashing a directory someone pointed at their whole home folder would hang
8
+ * before it was wrong, so it stops and says which key to narrow.
9
+ */
10
+ export declare const MAX_CONTEXT_FILES = 2000;
11
+ /** What a build is made of: the address every backend agrees on. */
12
+ export interface BuildIdentity {
13
+ /** 12 hex characters; the tag a build is known by */
14
+ hash: string;
15
+ /** every file in the context, relative and sorted — exactly what was hashed */
16
+ files: readonly string[];
17
+ }
18
+ /**
19
+ * The content address of a build: the Dockerfile, then every file the build can
20
+ * see, by path and by content, then its arguments.
21
+ *
22
+ * `.dockerignore` is not read. The engine honours it and this does not, so an
23
+ * ignored file that changes yields a new tag and a build that produces the same
24
+ * image — wasteful, never wrong, and the alternative is reimplementing a match
25
+ * syntax whose disagreements would be silent.
26
+ */
27
+ export declare function buildIdentity(spec: BuildSpec): BuildIdentity;
28
+ /** The tag a build is run under on this machine. */
29
+ export declare function localImageTag(build: BuildSpec | BuildIdentity): string;
30
+ /**
31
+ * A build that ran and failed, rather than an engine that could not be asked.
32
+ * Both stop a run, but only one of them is the project's fault: `zen check`
33
+ * reports a Dockerfile that does not build as an error, and a laptop without
34
+ * podman as a warning.
35
+ */
36
+ export declare class ImageBuildError extends SandboxError {
37
+ constructor(message: string, hint?: string);
38
+ }
39
+ /**
40
+ * Whether `err` is a failed build, by name rather than by class: a plugin
41
+ * that provides the sandbox may bring its own copy of this package, and its
42
+ * `ImageBuildError` is then a different class that means the same thing.
43
+ */
44
+ export declare function isImageBuildError(err: unknown): err is ImageBuildError;
45
+ /**
46
+ * Puts the image on this machine's engine: nothing when it is already there,
47
+ * a build when the spec names a Dockerfile, a pull otherwise.
48
+ *
49
+ * Skipping a present build is safe in a way it would not be for an ordinary
50
+ * tag, because this one is a hash of the Dockerfile and its context: the image
51
+ * existing *means* the content is unchanged. What it does not cover is a moved
52
+ * base image — `podman build` reuses whatever `FROM node:24` resolved to last
53
+ * time — which is what `force` is for.
54
+ */
55
+ export declare function ensurePodmanImage(target: {
56
+ image: string;
57
+ build?: BuildSpec;
58
+ }, host?: {
59
+ engine?: string;
60
+ exec?: Runner;
61
+ }, opts?: PrepareOptions): Promise<void>;
62
+ //# sourceMappingURL=image.d.ts.map