@zenera/sandbox 0.0.0-stage → 1.1.56
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 +21 -0
- package/README.md +46 -2
- package/dist/contract.d.ts +194 -0
- package/dist/contract.js +27 -0
- package/dist/fs.d.ts +46 -0
- package/dist/fs.js +39 -0
- package/dist/in-memory-fs.d.ts +25 -0
- package/dist/in-memory-fs.js +165 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +11 -0
- package/dist/limits.d.ts +19 -0
- package/dist/limits.js +33 -0
- package/dist/mounts.d.ts +84 -0
- package/dist/mounts.js +63 -0
- package/dist/podman.d.ts +76 -0
- package/dist/podman.js +555 -0
- package/dist/process.d.ts +29 -0
- package/dist/process.js +122 -0
- package/dist/testing.d.ts +25 -0
- package/dist/testing.js +112 -0
- package/package.json +58 -5
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,47 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @zenera/sandbox
|
|
2
2
|
|
|
3
|
-
|
|
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`** - `exec`, background jobs (`startJob`, `readJob`, `stopJob`),
|
|
21
|
+
`close` at the end of a turn, and the files: `fs` (an `FsDriver`) and
|
|
22
|
+
`files(view)`, which maps the host's workspace and mounts to the paths `fs`
|
|
23
|
+
finds them at. The invariant: **a file written through `fs` is the file
|
|
24
|
+
`exec` sees under the same name, and the reverse.**
|
|
25
|
+
- **`SandboxFactory`** - `(opts: SandboxOptions) => Sandbox`, how a host picks
|
|
26
|
+
where sandboxes run.
|
|
27
|
+
- **`FsDriver`** - the filesystem seam, usable without a sandbox. `localFs` is
|
|
28
|
+
this machine's disk; `InMemoryFs` keeps a tree in memory.
|
|
29
|
+
|
|
30
|
+
## Implementations
|
|
31
|
+
|
|
32
|
+
- **`PodmanSandbox`** / `podmanSandbox` - a podman container on this machine,
|
|
33
|
+
binding host directories, with `fs = localFs`.
|
|
34
|
+
- A sandbox that keeps its files with the container (a sandbox service) serves
|
|
35
|
+
`fs` itself and returns `inSandboxPaths(view, fs)` from `files()`.
|
|
36
|
+
|
|
37
|
+
## Testing an implementation
|
|
38
|
+
|
|
39
|
+
`@zenera/sandbox/testing` exports the contract as vitest suites, the same ones
|
|
40
|
+
this package runs against its own implementations:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { describeFsDriver, describeSandbox } from '@zenera/sandbox/testing';
|
|
44
|
+
|
|
45
|
+
describeFsDriver('remote fs', () => ({ fs: new RemoteFs(url), root: '/workspace' }));
|
|
46
|
+
describeSandbox('remote', () => ({ sandbox: openRemote(opts), root: opts.root }));
|
|
47
|
+
```
|
|
@@ -0,0 +1,194 @@
|
|
|
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
|
+
/** fractional cores, as podman's `--cpus` */
|
|
14
|
+
cpus?: number;
|
|
15
|
+
/** MiB, as podman's `--memory` */
|
|
16
|
+
memory?: number;
|
|
17
|
+
network?: SandboxNetwork;
|
|
18
|
+
/** where the workspace is mounted, and the default cwd */
|
|
19
|
+
workdir?: string;
|
|
20
|
+
/** default seconds a single command may take */
|
|
21
|
+
timeout?: number;
|
|
22
|
+
/** how much of the host the container is allowed to be; `standard` by default */
|
|
23
|
+
hardening?: SandboxHardening;
|
|
24
|
+
/** uid, name, or `uid:gid`; unset means the image's own user */
|
|
25
|
+
user?: string;
|
|
26
|
+
/** keep the container between sessions rather than removing it */
|
|
27
|
+
persist?: boolean;
|
|
28
|
+
/** environment inside the container — resolved values, never a passthrough */
|
|
29
|
+
env?: Record<string, string>;
|
|
30
|
+
/**
|
|
31
|
+
* Variables forwarded from this process by *name*, for credentials.
|
|
32
|
+
*
|
|
33
|
+
* Separate from `env` for two reasons, both about the value never being
|
|
34
|
+
* written down. `--env NAME` without an `=` tells podman to read the value
|
|
35
|
+
* out of its own environment, so the secret never appears on a command
|
|
36
|
+
* line, in `ps`, or in `podman inspect`. And the container's identity is
|
|
37
|
+
* computed from the names alone, so rotating a key does not abandon a
|
|
38
|
+
* persisted container's filesystem.
|
|
39
|
+
*/
|
|
40
|
+
secrets?: readonly string[];
|
|
41
|
+
}
|
|
42
|
+
export type SandboxNetwork = 'bridge' | 'none' | 'host';
|
|
43
|
+
/**
|
|
44
|
+
* Two postures, because they cannot both be the default.
|
|
45
|
+
*
|
|
46
|
+
* `standard` is the one this tool was built around: a container with an init,
|
|
47
|
+
* a pid ceiling and `no-new-privileges`, running an image the agent can still
|
|
48
|
+
* install into. Installing a package is the ordinary use of a shell, so the
|
|
49
|
+
* capabilities stay and the root filesystem stays writable.
|
|
50
|
+
*
|
|
51
|
+
* `strict` is for running code nobody has read. Every capability is dropped,
|
|
52
|
+
* the root filesystem is read-only, the only writable temporary space is an
|
|
53
|
+
* in-memory `/tmp` with `noexec,nosuid,nodev`, the process runs as the host's
|
|
54
|
+
* own unprivileged uid rather than as container root, and the network is off
|
|
55
|
+
* unless the project turns it back on. What that costs is exactly what it
|
|
56
|
+
* removes: `apt`, `pip` and anything else that writes outside the mounts stop
|
|
57
|
+
* working, so a strict project bakes its dependencies into the image instead.
|
|
58
|
+
*
|
|
59
|
+
* What it does *not* do is make the bind mounts go away. `/workspace` is the
|
|
60
|
+
* work itself and stays writable — the profile shrinks the blast radius of a
|
|
61
|
+
* command, it does not pretend the agent has nothing to edit.
|
|
62
|
+
*/
|
|
63
|
+
export type SandboxHardening = 'standard' | 'strict';
|
|
64
|
+
export interface SandboxOptions extends SandboxSpec {
|
|
65
|
+
/** host directory mounted at `workdir` */
|
|
66
|
+
root: string;
|
|
67
|
+
/**
|
|
68
|
+
* Stable name for whatever owns this sandbox — a session id, typically.
|
|
69
|
+
* It prefixes the container name, so a crashed process leaves something a
|
|
70
|
+
* human can recognise rather than a hash.
|
|
71
|
+
*/
|
|
72
|
+
key: string;
|
|
73
|
+
/** refuse to mount the workspace writable */
|
|
74
|
+
readOnly?: boolean;
|
|
75
|
+
/** extra bind mounts; the host side must already exist */
|
|
76
|
+
mounts?: readonly Mount[];
|
|
77
|
+
/**
|
|
78
|
+
* Environment set on every command rather than on the container: values
|
|
79
|
+
* that belong to the run, not to the container — the user it is acting for.
|
|
80
|
+
*
|
|
81
|
+
* `env` is fixed when the container is created, and a persisted container
|
|
82
|
+
* is kept between sessions; a run's values written there
|
|
83
|
+
* would still be answering for whoever came before. Not part of the
|
|
84
|
+
* container's name either, for the same reason in reverse: a new user is
|
|
85
|
+
* not a new machine. Plain values on podman's command line, so nothing
|
|
86
|
+
* secret goes here — that is what `secrets` is for.
|
|
87
|
+
*/
|
|
88
|
+
execEnv?: Record<string, string>;
|
|
89
|
+
/**
|
|
90
|
+
* Directories put in front of a path-like variable for every command —
|
|
91
|
+
* `PYTHONPATH`, say — keeping whatever the image already set behind them.
|
|
92
|
+
* `--env` could only replace the image's value, and an image that relies on
|
|
93
|
+
* its own `PYTHONPATH` would break on the first `python`.
|
|
94
|
+
*/
|
|
95
|
+
execPaths?: Record<string, readonly string[]>;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* One session's sandbox. `PodmanSandbox` is one on this machine; a remote
|
|
99
|
+
* sandbox service is another. The tools and the job model are the same for
|
|
100
|
+
* both.
|
|
101
|
+
*/
|
|
102
|
+
export interface Sandbox {
|
|
103
|
+
/**
|
|
104
|
+
* Derived from the spec, so a persisted sandbox is found again by an equal
|
|
105
|
+
* spec, and a host can tell a reused one from a new one.
|
|
106
|
+
*/
|
|
107
|
+
readonly name: string;
|
|
108
|
+
/** the files every command sees, under the same names */
|
|
109
|
+
readonly fs: FsDriver;
|
|
110
|
+
/**
|
|
111
|
+
* The file tools' view of this sandbox, in its own paths: given the host's
|
|
112
|
+
* workspace and mounts, where `fs` actually finds each of them.
|
|
113
|
+
*/
|
|
114
|
+
files(opts: FilesView): FilesView;
|
|
115
|
+
/** whether anything was actually started — `close` is free when not */
|
|
116
|
+
readonly started: boolean;
|
|
117
|
+
/** idempotent and single-flight; every other call starts the sandbox itself */
|
|
118
|
+
start(): Promise<void>;
|
|
119
|
+
exec(command: string, opts?: ExecOptions): Promise<ExecResult>;
|
|
120
|
+
/** starts a command and returns its job id; the output goes to a log */
|
|
121
|
+
startJob(command: string, opts?: ExecOptions): Promise<string>;
|
|
122
|
+
/**
|
|
123
|
+
* A window of a job's log, from line `from` (1-based), plus whether it is
|
|
124
|
+
* still going. An unknown id is a `SandboxError`.
|
|
125
|
+
*/
|
|
126
|
+
readJob(id: string, from?: number): Promise<JobOutput>;
|
|
127
|
+
/** Signals the job and everything it started. An unknown id is a `SandboxError`. */
|
|
128
|
+
stopJob(id: string): Promise<JobStopped>;
|
|
129
|
+
/**
|
|
130
|
+
* The session is done for now (the end of a turn): stops a persisted
|
|
131
|
+
* sandbox, removes an ephemeral one, and keeps the files — a sandbox that
|
|
132
|
+
* holds them elsewhere persists them here.
|
|
133
|
+
*/
|
|
134
|
+
close(): Promise<void>;
|
|
135
|
+
}
|
|
136
|
+
/** Opens a session's sandbox. Cheap: nothing starts until it is used. */
|
|
137
|
+
export type SandboxFactory = (opts: SandboxOptions) => Sandbox;
|
|
138
|
+
/** A failure the caller can do something about — a missing engine, usually. */
|
|
139
|
+
export declare class SandboxError extends Error {
|
|
140
|
+
readonly hint?: string;
|
|
141
|
+
constructor(message: string, hint?: string);
|
|
142
|
+
}
|
|
143
|
+
export interface ExecOptions {
|
|
144
|
+
cwd?: string;
|
|
145
|
+
/** seconds; the spec's default otherwise */
|
|
146
|
+
timeout?: number;
|
|
147
|
+
signal?: AbortSignal;
|
|
148
|
+
}
|
|
149
|
+
export interface ExecResult {
|
|
150
|
+
exit_code: number;
|
|
151
|
+
stdout: string;
|
|
152
|
+
stderr: string;
|
|
153
|
+
truncated?: boolean;
|
|
154
|
+
timed_out?: boolean;
|
|
155
|
+
duration_ms: number;
|
|
156
|
+
out_of_memory?: OutOfMemory;
|
|
157
|
+
}
|
|
158
|
+
export interface JobOutput {
|
|
159
|
+
job_id: string;
|
|
160
|
+
command: string;
|
|
161
|
+
running: boolean;
|
|
162
|
+
/** once it has ended */
|
|
163
|
+
exit_code?: number;
|
|
164
|
+
duration_ms: number;
|
|
165
|
+
/** the window returned, 1-based and inclusive */
|
|
166
|
+
start_line: number;
|
|
167
|
+
end_line: number;
|
|
168
|
+
/** how long the log is now */
|
|
169
|
+
lines: number;
|
|
170
|
+
/** more of the log follows `end_line` */
|
|
171
|
+
truncated?: boolean;
|
|
172
|
+
output: string;
|
|
173
|
+
out_of_memory?: OutOfMemory;
|
|
174
|
+
}
|
|
175
|
+
export interface JobStopped {
|
|
176
|
+
job_id: string;
|
|
177
|
+
stopped: boolean;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Why a command was killed for memory, which is a different question from
|
|
181
|
+
* whether it was. A container that overran its own `--memory` will do it again
|
|
182
|
+
* however long anyone waits; one the kernel sacrificed for a neighbour may
|
|
183
|
+
* simply succeed later. Exit code 137 is all either of them says.
|
|
184
|
+
*/
|
|
185
|
+
export interface OutOfMemory {
|
|
186
|
+
/** what this container was allowed */
|
|
187
|
+
limit_mib: number;
|
|
188
|
+
/** high-water mark for the container's whole life, not for this command */
|
|
189
|
+
peak_mib?: number;
|
|
190
|
+
/** false when the limit itself is the problem, so an unchanged rerun ends the same way */
|
|
191
|
+
retryable: boolean;
|
|
192
|
+
hint: string;
|
|
193
|
+
}
|
|
194
|
+
//# sourceMappingURL=contract.d.ts.map
|
package/dist/contract.js
ADDED
|
@@ -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,25 @@
|
|
|
1
|
+
import { type FsDriver, type FsEntry, type FsStat } from './fs.ts';
|
|
2
|
+
export declare class InMemoryFs implements FsDriver {
|
|
3
|
+
#private;
|
|
4
|
+
readonly path: import("path").PlatformPath;
|
|
5
|
+
/** Files to start with, by absolute path; their directories are made as needed. */
|
|
6
|
+
constructor(files?: Record<string, string | Uint8Array>);
|
|
7
|
+
/** An empty directory, with its parents. */
|
|
8
|
+
dir(at: string): this;
|
|
9
|
+
realpathSync(at: string): string;
|
|
10
|
+
stat(at: string): Promise<FsStat>;
|
|
11
|
+
readdir(at: string): Promise<FsEntry[]>;
|
|
12
|
+
readFile(at: string): Promise<Buffer>;
|
|
13
|
+
readHead(at: string, bytes: number): Promise<Buffer>;
|
|
14
|
+
writeFile(at: string, data: string | Uint8Array): Promise<void>;
|
|
15
|
+
mkdirp(at: string): Promise<void>;
|
|
16
|
+
rm(at: string, opts?: {
|
|
17
|
+
recursive?: boolean;
|
|
18
|
+
force?: boolean;
|
|
19
|
+
}): Promise<void>;
|
|
20
|
+
rename(from: string, to: string): Promise<void>;
|
|
21
|
+
copy(from: string, to: string, opts?: {
|
|
22
|
+
recursive?: boolean;
|
|
23
|
+
}): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=in-memory-fs.d.ts.map
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
import { fsError } from "./fs.js";
|
|
3
|
+
export class InMemoryFs {
|
|
4
|
+
path = posix;
|
|
5
|
+
#nodes = new Map([['/', { kind: 'dir' }]]);
|
|
6
|
+
/** Files to start with, by absolute path; their directories are made as needed. */
|
|
7
|
+
constructor(files = {}) {
|
|
8
|
+
for (const [at, data] of Object.entries(files)) {
|
|
9
|
+
this.#mkdirs(posix.dirname(this.#abs(at)));
|
|
10
|
+
this.#nodes.set(this.#abs(at), { kind: 'file', data: Buffer.from(data) });
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** An empty directory, with its parents. */
|
|
14
|
+
dir(at) {
|
|
15
|
+
this.#mkdirs(this.#abs(at));
|
|
16
|
+
return this;
|
|
17
|
+
}
|
|
18
|
+
realpathSync(at) {
|
|
19
|
+
const abs = this.#abs(at);
|
|
20
|
+
if (!this.#nodes.has(abs)) {
|
|
21
|
+
throw fsError('ENOENT', 'realpath', at);
|
|
22
|
+
}
|
|
23
|
+
return abs;
|
|
24
|
+
}
|
|
25
|
+
async stat(at) {
|
|
26
|
+
return info(this.#get(at, 'stat'));
|
|
27
|
+
}
|
|
28
|
+
async readdir(at) {
|
|
29
|
+
const abs = this.#abs(at);
|
|
30
|
+
if (this.#get(at, 'scandir').kind !== 'dir') {
|
|
31
|
+
throw fsError('ENOTDIR', 'scandir', at);
|
|
32
|
+
}
|
|
33
|
+
return this.#children(abs).map((child) => ({
|
|
34
|
+
name: posix.basename(child),
|
|
35
|
+
...info(this.#nodes.get(child)),
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
async readFile(at) {
|
|
39
|
+
const node = this.#get(at, 'open');
|
|
40
|
+
if (node.kind !== 'file') {
|
|
41
|
+
throw fsError('EISDIR', 'read', at);
|
|
42
|
+
}
|
|
43
|
+
return Buffer.from(node.data);
|
|
44
|
+
}
|
|
45
|
+
async readHead(at, bytes) {
|
|
46
|
+
return (await this.readFile(at)).subarray(0, bytes);
|
|
47
|
+
}
|
|
48
|
+
async writeFile(at, data) {
|
|
49
|
+
const abs = this.#abs(at);
|
|
50
|
+
this.#parent(abs, 'open');
|
|
51
|
+
if (this.#nodes.get(abs)?.kind === 'dir') {
|
|
52
|
+
throw fsError('EISDIR', 'open', at);
|
|
53
|
+
}
|
|
54
|
+
this.#nodes.set(abs, { kind: 'file', data: Buffer.from(data) });
|
|
55
|
+
}
|
|
56
|
+
async mkdirp(at) {
|
|
57
|
+
this.#mkdirs(this.#abs(at));
|
|
58
|
+
}
|
|
59
|
+
async rm(at, opts = {}) {
|
|
60
|
+
const abs = this.#abs(at);
|
|
61
|
+
const node = this.#nodes.get(abs);
|
|
62
|
+
if (!node) {
|
|
63
|
+
if (opts.force) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
throw fsError('ENOENT', 'rm', at);
|
|
67
|
+
}
|
|
68
|
+
if (node.kind === 'dir') {
|
|
69
|
+
if (!opts.recursive) {
|
|
70
|
+
throw fsError('EISDIR', 'rm', at);
|
|
71
|
+
}
|
|
72
|
+
for (const key of this.#below(abs)) {
|
|
73
|
+
this.#nodes.delete(key);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
this.#nodes.delete(abs);
|
|
77
|
+
}
|
|
78
|
+
async rename(from, to) {
|
|
79
|
+
const src = this.#abs(from);
|
|
80
|
+
const dst = this.#abs(to);
|
|
81
|
+
const node = this.#get(from, 'rename');
|
|
82
|
+
this.#parent(dst, 'rename');
|
|
83
|
+
const there = this.#nodes.get(dst);
|
|
84
|
+
if (there?.kind === 'dir' && this.#children(dst).length > 0) {
|
|
85
|
+
throw fsError('ENOTEMPTY', 'rename', to);
|
|
86
|
+
}
|
|
87
|
+
const moved = node.kind === 'dir' ? this.#below(src) : [];
|
|
88
|
+
this.#nodes.delete(dst);
|
|
89
|
+
this.#nodes.set(dst, node);
|
|
90
|
+
this.#nodes.delete(src);
|
|
91
|
+
for (const key of moved) {
|
|
92
|
+
this.#nodes.set(dst + key.slice(src.length), this.#nodes.get(key));
|
|
93
|
+
this.#nodes.delete(key);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async copy(from, to, opts = {}) {
|
|
97
|
+
const src = this.#abs(from);
|
|
98
|
+
const dst = this.#abs(to);
|
|
99
|
+
const node = this.#get(from, 'cp');
|
|
100
|
+
if (node.kind === 'dir' && !opts.recursive) {
|
|
101
|
+
throw fsError('EISDIR', 'cp', from);
|
|
102
|
+
}
|
|
103
|
+
this.#parent(dst, 'cp');
|
|
104
|
+
this.#nodes.set(dst, copyOf(node));
|
|
105
|
+
if (node.kind === 'dir') {
|
|
106
|
+
for (const key of this.#below(src)) {
|
|
107
|
+
this.#nodes.set(dst + key.slice(src.length), copyOf(this.#nodes.get(key)));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
#abs(at) {
|
|
112
|
+
return posix.resolve('/', at);
|
|
113
|
+
}
|
|
114
|
+
#get(at, syscall) {
|
|
115
|
+
const node = this.#nodes.get(this.#abs(at));
|
|
116
|
+
if (!node) {
|
|
117
|
+
throw fsError('ENOENT', syscall, at);
|
|
118
|
+
}
|
|
119
|
+
return node;
|
|
120
|
+
}
|
|
121
|
+
/** The parent has to be there already, as it does for `node:fs` without `recursive`. */
|
|
122
|
+
#parent(abs, syscall) {
|
|
123
|
+
const up = this.#nodes.get(posix.dirname(abs));
|
|
124
|
+
if (!up) {
|
|
125
|
+
throw fsError('ENOENT', syscall, abs);
|
|
126
|
+
}
|
|
127
|
+
if (up.kind !== 'dir') {
|
|
128
|
+
throw fsError('ENOTDIR', syscall, abs);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
#mkdirs(abs) {
|
|
132
|
+
if (abs === '/') {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
this.#mkdirs(posix.dirname(abs));
|
|
136
|
+
const node = this.#nodes.get(abs);
|
|
137
|
+
if (node?.kind === 'file') {
|
|
138
|
+
throw fsError('EEXIST', 'mkdir', abs);
|
|
139
|
+
}
|
|
140
|
+
if (!node) {
|
|
141
|
+
this.#nodes.set(abs, { kind: 'dir' });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
#children(abs) {
|
|
145
|
+
const prefix = abs === '/' ? '/' : `${abs}/`;
|
|
146
|
+
return [...this.#nodes.keys()]
|
|
147
|
+
.filter((k) => k !== abs && k.startsWith(prefix) && !k.slice(prefix.length).includes('/'))
|
|
148
|
+
.sort();
|
|
149
|
+
}
|
|
150
|
+
#below(abs) {
|
|
151
|
+
return [...this.#nodes.keys()].filter((k) => k.startsWith(`${abs}/`));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function info(node) {
|
|
155
|
+
return {
|
|
156
|
+
size: node.kind === 'file' ? node.data.byteLength : 0,
|
|
157
|
+
isFile: () => node.kind === 'file',
|
|
158
|
+
isDirectory: () => node.kind === 'dir',
|
|
159
|
+
isSymbolicLink: () => false,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function copyOf(node) {
|
|
163
|
+
return node.kind === 'file' ? { kind: 'file', data: Buffer.from(node.data) } : { kind: 'dir' };
|
|
164
|
+
}
|
|
165
|
+
//# sourceMappingURL=in-memory-fs.js.map
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The sandbox: where a session's commands run and the files they see. Depends
|
|
2
|
+
// on Node alone, so a host, a sandbox service and the agent inside a container
|
|
3
|
+
// can all stand on the same contract. Contract tests: `@zenera/sandbox/testing`.
|
|
4
|
+
export * from "./contract.js";
|
|
5
|
+
export * from "./fs.js";
|
|
6
|
+
export * from "./in-memory-fs.js";
|
|
7
|
+
export * from "./limits.js";
|
|
8
|
+
export * from "./mounts.js";
|
|
9
|
+
export * from "./podman.js";
|
|
10
|
+
export * from "./process.js";
|
|
11
|
+
//# sourceMappingURL=index.js.map
|
package/dist/limits.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { SandboxHardening, SandboxSpec } from './contract.ts';
|
|
2
|
+
export interface SandboxLimits {
|
|
3
|
+
/** MiB */
|
|
4
|
+
memory: number;
|
|
5
|
+
/** fractional cores */
|
|
6
|
+
cpus: number;
|
|
7
|
+
hardening: SandboxHardening;
|
|
8
|
+
/** whether the numbers above were written down, or are this profile's defaults */
|
|
9
|
+
declared: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* What a spec's ceilings come to, for a report that has no container to ask.
|
|
13
|
+
*
|
|
14
|
+
* It exists so `zen sandbox status` cannot disagree with what a run actually
|
|
15
|
+
* applies: the defaults are stated once, here and in `resolveSpec`, and
|
|
16
|
+
* nothing outside this module repeats them.
|
|
17
|
+
*/
|
|
18
|
+
export declare function sandboxLimits(spec?: Pick<SandboxSpec, 'cpus' | 'memory' | 'hardening'>): SandboxLimits;
|
|
19
|
+
//# sourceMappingURL=limits.d.ts.map
|
package/dist/limits.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// A sandbox's ceilings
|
|
3
|
+
//
|
|
4
|
+
// Stated once, here, so the sandbox that applies them and `zen sandbox status`
|
|
5
|
+
// that reports them cannot disagree.
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Standard mode's resource ceilings. Even without the strict profile, an
|
|
8
|
+
// unbounded container can exhaust host cores or memory during runaway builds.
|
|
9
|
+
const DEFAULT_CPUS = 4;
|
|
10
|
+
const DEFAULT_MEMORY = 4096;
|
|
11
|
+
// Strict mode's own defaults. A limit that is only there when someone
|
|
12
|
+
// remembered to write it down is not a limit, so the profile supplies one for
|
|
13
|
+
// each of the four exhaustible things: cores, memory, processes and disk.
|
|
14
|
+
const STRICT_CPUS = 2;
|
|
15
|
+
const STRICT_MEMORY = 2048;
|
|
16
|
+
/**
|
|
17
|
+
* What a spec's ceilings come to, for a report that has no container to ask.
|
|
18
|
+
*
|
|
19
|
+
* It exists so `zen sandbox status` cannot disagree with what a run actually
|
|
20
|
+
* applies: the defaults are stated once, here and in `resolveSpec`, and
|
|
21
|
+
* nothing outside this module repeats them.
|
|
22
|
+
*/
|
|
23
|
+
export function sandboxLimits(spec = {}) {
|
|
24
|
+
const hardening = spec.hardening ?? 'standard';
|
|
25
|
+
const strict = hardening === 'strict';
|
|
26
|
+
return {
|
|
27
|
+
memory: spec.memory ?? (strict ? STRICT_MEMORY : DEFAULT_MEMORY),
|
|
28
|
+
cpus: spec.cpus ?? (strict ? STRICT_CPUS : DEFAULT_CPUS),
|
|
29
|
+
hardening,
|
|
30
|
+
declared: spec.memory !== undefined || spec.cpus !== undefined,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=limits.js.map
|