@zenera/sandbox 1.1.58 → 1.2.0

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 CHANGED
@@ -10,8 +10,7 @@ import { podmanSandbox } from '@zenera/sandbox';
10
10
 
11
11
  const sandbox = podmanSandbox({ root: '/path/to/workspace', key: 'session-1' });
12
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');
13
+ await sandbox.fs.writeFile('/workspace/notes.md', 'hello'); // what `cat notes.md` sees
15
14
  await sandbox.close();
16
15
  ```
17
16
 
@@ -19,14 +18,18 @@ await sandbox.close();
19
18
 
20
19
  - **`Sandbox`** - `prepare` (pull the image, or build it from `build:`),
21
20
  `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.**
21
+ `close` at the end of a turn, and the files: `fs`, an `FsDriver` in the
22
+ agent's own paths (`/workspace/a.py`) and contained to the sandbox's trees.
23
+ The invariant: **a file written through `fs` is the file `exec` sees under
24
+ the same name, and the reverse.**
26
25
  - **`SandboxFactory`** - `(opts: SandboxOptions) => Sandbox`, how a host picks
27
26
  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.
27
+ - **`FsDriver`** - the filesystem seam, usable without a sandbox. `RootedFs`
28
+ serves named trees from directories on this machine - `/workspace` from the
29
+ session's folder, `/assets` read-only - and is the one place a path is
30
+ contained: it must name a tree, its symlinks must resolve into one, and a
31
+ read-only tree refuses every change. `localFs` is this machine's disk as it
32
+ is; `InMemoryFs` keeps a tree in memory.
30
33
 
31
34
  ## The image
32
35
 
@@ -43,10 +46,26 @@ by name, across copies of this package).
43
46
  ## Implementations
44
47
 
45
48
  - **`PodmanSandbox`** / `podmanSandbox` - a podman container on this machine,
46
- binding host directories, with `fs = localFs`. `ensurePodmanImage` is its
47
- image step on its own.
49
+ binding host directories, with `fs` a `RootedFs` over them under the names
50
+ they are bound at. `ensurePodmanImage` is its image step on its own.
51
+ - **`BwrapSandbox`** / `bwrapSandbox` - for a host that is itself the
52
+ container, such as the agent image: each command runs under bubblewrap
53
+ (`/opt/zenera/bin/bwrap`, else `bwrap` on PATH) in its own user, pid, ipc and
54
+ uts namespaces, on a read-only view of the image, with the session's
55
+ directories bound in and the server's own paths (`/state`, `/run/secrets`)
56
+ hidden. bwrap is started with exactly the command's environment, never this
57
+ process's: the image's `ENV` (`/opt/zenera/etc/image-env.json`, written when
58
+ the agent image is built; else `PATH`, `LANG`, `LC_*`, `TZ`) plus the run's.
59
+ `/proc` is the command's own where the container lets one be
60
+ mounted, else the container's bound read-only (`probeBwrap()` says which, or
61
+ why bwrap cannot isolate anything; nothing falls back to running unisolated).
62
+ `prepare()` builds nothing: it checks that the container was built from the
63
+ project's Dockerfile (`ZEN_IMAGE_BUILD`) or image (`ZEN_IMAGE_BASE`), when
64
+ it says what it was built from.
65
+ `cpus`, `memory` and `persist` belong to the container and are ignored;
66
+ commands get `oom_score_adj` 1000, so they go before the server.
48
67
  - A sandbox that keeps its files with the container (a sandbox service) serves
49
- `fs` itself and returns `inSandboxPaths(view, fs)` from `files()`.
68
+ `fs` itself, under the same names.
50
69
 
51
70
  ## Testing an implementation
52
71
 
@@ -0,0 +1,143 @@
1
+ import { type Mount } from './mounts.ts';
2
+ import { type Runner } from './process.ts';
3
+ import { RootedFs } from './rooted-fs.ts';
4
+ import { type BuildSpec, type ExecOptions, type ExecResult, type JobOutput, type JobStopped, type PrepareOptions, type Sandbox, type SandboxFactory, type SandboxHardening, type SandboxNetwork, type SandboxOptions } from './contract.ts';
5
+ /** Where the agent image keeps its bwrap: a static build, so any base runs it. */
6
+ export declare const ZENERA_BWRAP = "/opt/zenera/bin/bwrap";
7
+ /**
8
+ * The project image's own environment (its Dockerfile's `ENV` lines), written by the agent
9
+ * image's build as a JSON object: what a command gets to start from, as it would in that image.
10
+ */
11
+ export declare const IMAGE_ENV_FILE = "/opt/zenera/etc/image-env.json";
12
+ /** Set by `zen image build`: what the image was built from. */
13
+ export declare const IMAGE_BUILD_ENV = "ZEN_IMAGE_BUILD";
14
+ export declare const IMAGE_BASE_ENV = "ZEN_IMAGE_BASE";
15
+ /** `--proc`: a `/proc` of the command's own, or the container's, bound read-only. */
16
+ export type ProcMode = 'fresh' | 'bind';
17
+ export interface BwrapProbe {
18
+ ok: boolean;
19
+ /** the binary probed */
20
+ bwrap: string;
21
+ /** how `/proc` is given to commands, when `ok` */
22
+ proc?: ProcMode;
23
+ /** why not, when not `ok` */
24
+ error?: string;
25
+ hint?: string;
26
+ }
27
+ /** What a host can tell the sandbox about where it runs; the defaults are the agent image's. */
28
+ export interface BwrapHost {
29
+ /** the bwrap binary; `/opt/zenera/bin/bwrap`, else `bwrap` on PATH */
30
+ bwrap?: string;
31
+ /** where the base environment and secrets are read from; `process.env` */
32
+ env?: NodeJS.ProcessEnv;
33
+ /** more paths no command sees, besides `/state` and `/run/secrets` (always hidden) */
34
+ hidden?: readonly string[];
35
+ /**
36
+ * What this image was built from, which `prepare` checks the spec against;
37
+ * from `ZEN_IMAGE_BUILD` / `ZEN_IMAGE_BASE` when unset. Neither known, or
38
+ * `false`: no check — the container is whatever its host made it.
39
+ */
40
+ image?: false | {
41
+ build?: string;
42
+ base?: string;
43
+ };
44
+ /**
45
+ * What a command's environment starts from: the image's own `ENV` (IMAGE_ENV_FILE, when the
46
+ * container has it), else PATH, LANG, LC_* and TZ of this process. Never the rest of it.
47
+ */
48
+ imageEnv?: Record<string, string>;
49
+ /** how processes are spawned — the seam tests replace */
50
+ exec?: Runner;
51
+ /** the host's filesystem as the root is built from it — the seam tests replace */
52
+ tree?: HostTree;
53
+ }
54
+ /** The host's filesystem, as far as building a command's root needs it. */
55
+ export interface HostTree {
56
+ kind(path: string): 'dir' | 'file' | 'symlink' | 'missing';
57
+ list(path: string): string[];
58
+ link(path: string): string;
59
+ }
60
+ export declare const hostTree: HostTree;
61
+ interface Resolved {
62
+ root: string;
63
+ key: string;
64
+ image: string;
65
+ build?: BuildSpec;
66
+ network: SandboxNetwork;
67
+ workdir: string;
68
+ timeout: number;
69
+ hardening: SandboxHardening;
70
+ user?: {
71
+ uid: string;
72
+ gid?: string;
73
+ };
74
+ env: Record<string, string>;
75
+ secrets: readonly string[];
76
+ readOnly: boolean;
77
+ mounts: readonly Mount[];
78
+ execEnv: Record<string, string>;
79
+ execPaths: Record<string, readonly string[]>;
80
+ }
81
+ export declare class BwrapSandbox implements Sandbox {
82
+ #private;
83
+ readonly name: string;
84
+ readonly fs: RootedFs;
85
+ readonly spec: Resolved;
86
+ constructor(opts: SandboxOptions, host?: BwrapHost);
87
+ get started(): boolean;
88
+ /** Checks that this container is the image the project describes; builds nothing. */
89
+ prepare(opts?: PrepareOptions): Promise<void>;
90
+ start(): Promise<void>;
91
+ exec(command: string, opts?: ExecOptions): Promise<ExecResult>;
92
+ /**
93
+ * Starts a command and returns. Its log is a file of this process's, outside
94
+ * the command's view; its exit code is the one bwrap reports.
95
+ */
96
+ startJob(command: string, opts?: ExecOptions): Promise<string>;
97
+ readJob(id: string, from?: number): Promise<JobOutput>;
98
+ /**
99
+ * Ends bwrap, and with it everything the job started: the command is the
100
+ * first process of a pid namespace, and a namespace whose first process
101
+ * dies takes the rest with it.
102
+ */
103
+ stopJob(id: string): Promise<JobStopped>;
104
+ /** Ends every command and job; the files stay where the mounts keep them. */
105
+ close(): Promise<void>;
106
+ /** A path inside the sandbox, contained to the working directory. */
107
+ inside(path?: string): string;
108
+ }
109
+ export declare const bwrapSandbox: SandboxFactory;
110
+ export interface BwrapArgs {
111
+ tree: HostTree;
112
+ hidden: readonly string[];
113
+ /** shallowest first */
114
+ binds: readonly {
115
+ at: string;
116
+ host: string;
117
+ readOnly: boolean;
118
+ }[];
119
+ proc: ProcMode;
120
+ network: SandboxNetwork;
121
+ strict: boolean;
122
+ user?: {
123
+ uid: string;
124
+ gid?: string;
125
+ };
126
+ cwd: string;
127
+ /** more mounts, after `/tmp` exists */
128
+ extra?: readonly string[];
129
+ command: readonly string[];
130
+ }
131
+ export declare function bwrapArgs(a: BwrapArgs): string[];
132
+ /**
133
+ * The host's root, bound piece by piece: `targets` are mounted later, `omit`
134
+ * is never shown. A directory is opened only for a mount point it lacks;
135
+ * whatever it holds is then bound entry by entry, so this stays rare — under
136
+ * SELinux a bind of something the runtime itself mounted can be refused.
137
+ */
138
+ export declare function rootArgs(tree: HostTree, targets: readonly string[], omit: readonly string[]): string[];
139
+ /** What is hidden inside a bound directory is covered: an empty tmpfs, or /dev/null. */
140
+ export declare function hiddenArgs(tree: HostTree, hidden: readonly string[]): string[];
141
+ export declare function probeBwrap(bwrap?: string, run?: Runner): Promise<BwrapProbe>;
142
+ export {};
143
+ //# sourceMappingURL=bwrap.d.ts.map