@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/dist/image.js ADDED
@@ -0,0 +1,210 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, readdirSync } from 'node:fs';
3
+ import { join, relative, sep } from 'node:path';
4
+ import { SandboxError } from "./contract.js";
5
+ import { runProcess } from "./process.js";
6
+ // ---------------------------------------------------------------------------
7
+ // The sandbox image
8
+ //
9
+ // `image:` names something to pull; `build:` names a Dockerfile to build. Both
10
+ // end as an image a sandbox runs, and getting it there is the sandbox's job:
11
+ // podman builds on this machine, a sandbox service builds wherever it builds.
12
+ // What they share is the *identity* of a build — which files go in and what
13
+ // they hash to — so a project names the same image on every backend.
14
+ //
15
+ // The tag is a function of what goes into the image, and it has to be: a
16
+ // container's name hashes its image, so a tag that stayed put while the
17
+ // Dockerfile changed would leave a `persist: true` container running last
18
+ // week's filesystem, with nothing anywhere saying so. Hashing the context as
19
+ // well as the Dockerfile means an edit to either yields a new tag, a new
20
+ // container name, and a build — which is the only honest answer.
21
+ // ---------------------------------------------------------------------------
22
+ /** Where locally built images are tagged. Not scoped: a `/` or an `@` is not a legal tag. */
23
+ export const LOCAL_IMAGE_REPO = 'localhost/zenera-sandbox';
24
+ /**
25
+ * A build context is meant to be small — a Dockerfile and whatever it copies.
26
+ * Hashing a directory someone pointed at their whole home folder would hang
27
+ * before it was wrong, so it stops and says which key to narrow.
28
+ */
29
+ export const MAX_CONTEXT_FILES = 2_000;
30
+ /**
31
+ * The content address of a build: the Dockerfile, then every file the build can
32
+ * see, by path and by content, then its arguments.
33
+ *
34
+ * `.dockerignore` is not read. The engine honours it and this does not, so an
35
+ * ignored file that changes yields a new tag and a build that produces the same
36
+ * image — wasteful, never wrong, and the alternative is reimplementing a match
37
+ * syntax whose disagreements would be silent.
38
+ */
39
+ export function buildIdentity(spec) {
40
+ const hash = createHash('sha256');
41
+ hash.update(readFileSync(spec.dockerfile));
42
+ const files = walk(spec.context);
43
+ for (const rel of files) {
44
+ // The separator is hashed as posix so the same tree tags the same on
45
+ // any host.
46
+ hash.update(`\0${rel.split(sep).join('/')}\0`);
47
+ hash.update(readFileSync(join(spec.context, rel)));
48
+ }
49
+ // Only when there are any, so a build without arguments keeps the tag it
50
+ // had before arguments existed.
51
+ const args = Object.entries(spec.args ?? {}).sort(([a], [b]) => a.localeCompare(b));
52
+ if (args.length > 0) {
53
+ hash.update(`\0args\0${JSON.stringify(args)}`);
54
+ }
55
+ return { hash: hash.digest('hex').slice(0, 12), files };
56
+ }
57
+ /** The tag a build is run under on this machine. */
58
+ export function localImageTag(build) {
59
+ const id = 'hash' in build ? build : buildIdentity(build);
60
+ return `${LOCAL_IMAGE_REPO}:${id.hash}`;
61
+ }
62
+ /** Every file under `dir`, relative and sorted, so the digest is stable. */
63
+ function walk(dir) {
64
+ const found = [];
65
+ const pending = [dir];
66
+ while (pending.length > 0) {
67
+ const at = pending.pop();
68
+ for (const entry of readdirSync(at, { withFileTypes: true })) {
69
+ const path = join(at, entry.name);
70
+ if (entry.isDirectory()) {
71
+ pending.push(path);
72
+ }
73
+ else if (entry.isFile()) {
74
+ found.push(relative(dir, path));
75
+ }
76
+ }
77
+ if (found.length > MAX_CONTEXT_FILES) {
78
+ throw new SandboxError(`sandbox.build.context: ${dir} holds more than ${MAX_CONTEXT_FILES} files`, 'point `context:` at the directory the build actually copies from');
79
+ }
80
+ }
81
+ return found.sort();
82
+ }
83
+ // ---------------------------------------------------------------------------
84
+ // On this machine
85
+ // ---------------------------------------------------------------------------
86
+ /**
87
+ * A build that ran and failed, rather than an engine that could not be asked.
88
+ * Both stop a run, but only one of them is the project's fault: `zen check`
89
+ * reports a Dockerfile that does not build as an error, and a laptop without
90
+ * podman as a warning.
91
+ */
92
+ export class ImageBuildError extends SandboxError {
93
+ constructor(message, hint) {
94
+ super(message, hint);
95
+ this.name = 'ImageBuildError';
96
+ }
97
+ }
98
+ /**
99
+ * Whether `err` is a failed build, by name rather than by class: a plugin
100
+ * that provides the sandbox may bring its own copy of this package, and its
101
+ * `ImageBuildError` is then a different class that means the same thing.
102
+ */
103
+ export function isImageBuildError(err) {
104
+ return err instanceof Error && err.name === 'ImageBuildError';
105
+ }
106
+ /** Pulling is slow on purpose... */
107
+ const PULL_MS = 600_000;
108
+ /** ...and a build from a cold cache is slower still. */
109
+ const BUILD_MS = 900_000;
110
+ /**
111
+ * Images this process has already settled, per runner and per engine. A batch
112
+ * opens the same project dozens of times at once, and without this each one
113
+ * asks podman and the concurrent copies race each other to pull or build the
114
+ * very same image. Keyed by runner too, so a test's fake engine never inherits
115
+ * an answer the real one gave.
116
+ */
117
+ const settled = new WeakMap();
118
+ /**
119
+ * Puts the image on this machine's engine: nothing when it is already there,
120
+ * a build when the spec names a Dockerfile, a pull otherwise.
121
+ *
122
+ * Skipping a present build is safe in a way it would not be for an ordinary
123
+ * tag, because this one is a hash of the Dockerfile and its context: the image
124
+ * existing *means* the content is unchanged. What it does not cover is a moved
125
+ * base image — `podman build` reuses whatever `FROM node:24` resolved to last
126
+ * time — which is what `force` is for.
127
+ */
128
+ export function ensurePodmanImage(target, host = {}, opts = {}) {
129
+ const engine = host.engine ?? 'podman';
130
+ const run = host.exec ?? runProcess;
131
+ let byKey = settled.get(run);
132
+ if (!byKey) {
133
+ byKey = new Map();
134
+ settled.set(run, byKey);
135
+ }
136
+ const key = `${engine}::${target.image}`;
137
+ const pending = byKey.get(key);
138
+ if (pending && !opts.force) {
139
+ return pending;
140
+ }
141
+ const attempt = obtain(engine, run, target, opts).catch((err) => {
142
+ // A failure is not a settled answer: the user may fix the Dockerfile
143
+ // and try again in the same process.
144
+ byKey.delete(key);
145
+ throw err;
146
+ });
147
+ byKey.set(key, attempt);
148
+ return attempt;
149
+ }
150
+ async function obtain(engine, run, target, opts) {
151
+ const say = (line) => opts.onProgress?.(line);
152
+ const slow = (label, work) => opts.slow ? opts.slow(label, work) : work;
153
+ const tail = (res) => {
154
+ for (const line of res.stderr.split('\n').slice(-3)) {
155
+ if (line.trim()) {
156
+ say(` ${line.trim()}`);
157
+ }
158
+ }
159
+ };
160
+ if (!opts.force) {
161
+ const present = await run(engine, ['image', 'exists', target.image], {
162
+ timeoutMs: 30_000,
163
+ });
164
+ if (present.code === 0) {
165
+ return;
166
+ }
167
+ }
168
+ const build = target.build;
169
+ if (build) {
170
+ if (opts.allowBuild === false) {
171
+ throw new SandboxError(`the sandbox image ${target.image} is not built`, 'build it before running: `zen sandbox up` builds it on this machine');
172
+ }
173
+ say(`${opts.force ? 'rebuilding' : 'building'} the sandbox image from ${build.dockerfile}`);
174
+ say(' the agent runs its commands inside a container, so the image is needed first');
175
+ say(' this takes a few minutes; later runs reuse it');
176
+ const args = Object.entries(build.args ?? {}).flatMap(([k, v]) => [
177
+ '--build-arg',
178
+ `${k}=${v}`,
179
+ ]);
180
+ const built = await slow('building the sandbox image', run(engine, [
181
+ 'build',
182
+ '--tag',
183
+ target.image,
184
+ '--file',
185
+ build.dockerfile,
186
+ ...args,
187
+ build.context,
188
+ ], { timeoutMs: BUILD_MS, signal: opts.signal }));
189
+ tail(built);
190
+ if (built.code !== 0) {
191
+ throw new ImageBuildError(`could not build ${build.dockerfile}`, last(built) || 'run the build by hand to see what the engine says');
192
+ }
193
+ return;
194
+ }
195
+ say(`pulling ${target.image} — this happens once`);
196
+ const pulled = await slow('pulling', run(engine, ['pull', target.image], { timeoutMs: PULL_MS, signal: opts.signal }));
197
+ tail(pulled);
198
+ if (pulled.code !== 0) {
199
+ throw new SandboxError(`could not pull ${target.image}`, first(pulled) || 'check the image name and your network');
200
+ }
201
+ }
202
+ function first(res) {
203
+ return (res.stderr.trim() || res.stdout.trim()).split('\n')[0] ?? '';
204
+ }
205
+ /** A build says what went wrong on its last line, not its first. */
206
+ function last(res) {
207
+ const lines = (res.stderr.trim() || res.stdout.trim()).split('\n').filter((l) => l.trim());
208
+ return lines.slice(-2).join(' — ');
209
+ }
210
+ //# sourceMappingURL=image.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
@@ -0,0 +1,9 @@
1
+ export * from './contract.ts';
2
+ export * from './fs.ts';
3
+ export * from './image.ts';
4
+ export * from './in-memory-fs.ts';
5
+ export * from './limits.ts';
6
+ export * from './mounts.ts';
7
+ export * from './podman.ts';
8
+ export * from './process.ts';
9
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,12 @@
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 "./image.js";
7
+ export * from "./in-memory-fs.js";
8
+ export * from "./limits.js";
9
+ export * from "./mounts.js";
10
+ export * from "./podman.js";
11
+ export * from "./process.js";
12
+ //# sourceMappingURL=index.js.map
@@ -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
@@ -0,0 +1,84 @@
1
+ import type { FsDriver } from './fs.ts';
2
+ /** Where the workspace appears in the agent's namespace, and the default cwd. */
3
+ export declare const WORKSPACE_MOUNT = "/workspace";
4
+ /** Where a project's `assets/` directory appears. */
5
+ export declare const ASSETS_MOUNT = "/assets";
6
+ /** ...and where its skill catalog does, so a skill's scripts can be run. */
7
+ export declare const SKILLS_MOUNT = "/skills";
8
+ export type MountSource =
9
+ /** the project's reference material, at `/assets` */
10
+ {
11
+ kind: 'assets';
12
+ }
13
+ /** the skill catalog — one merged tree at `/skills`, however many folders it came from */
14
+ | {
15
+ kind: 'skills';
16
+ }
17
+ /** the container's private, persistent `$HOME` */
18
+ | {
19
+ kind: 'home';
20
+ }
21
+ /** one credential file, by the variable that names it */
22
+ | {
23
+ kind: 'credential';
24
+ name: string;
25
+ };
26
+ export interface Mount {
27
+ /** absolute host path; must already exist */
28
+ host: string;
29
+ /** absolute path the agent names it by, e.g. `/assets` */
30
+ at: string;
31
+ /**
32
+ * Say it. Unset, the file tools treat an extra tree as reference material
33
+ * and refuse to write it, while the sandbox binds it writable — the container's
34
+ * `$HOME` is the one mount that relies on that.
35
+ */
36
+ readOnly?: boolean;
37
+ /** what the tree is, for a backend that has to supply it rather than bind `host` */
38
+ source?: MountSource;
39
+ }
40
+ /**
41
+ * The trees a file tool reaches, and the names it reaches them by: the
42
+ * workspace, the name it is also mounted at, and the extra trees beside it.
43
+ * What `Sandbox.files` translates into the sandbox's own paths, and what the
44
+ * file tools in `@zenera/neo` take.
45
+ */
46
+ export interface FilesView {
47
+ root: string;
48
+ /** refuse every mutating tool; `zen run --read-only` */
49
+ readOnly?: boolean;
50
+ /**
51
+ * An absolute path the root is *also* known by, because something else has
52
+ * mounted it there — the sandbox mounts the workspace at `/workspace`, so
53
+ * `run_command` returns compiler errors, stack traces and `pwd` output
54
+ * naming files under a prefix the file tools would otherwise refuse. Set
55
+ * it and both spellings reach the same file.
56
+ */
57
+ mount?: string;
58
+ /** further trees, each under its own name; read-only unless one says otherwise */
59
+ mounts?: readonly Mount[];
60
+ /**
61
+ * Where the bytes are; this machine's disk when unset. `root` and every
62
+ * mount's `host` are paths in this driver's namespace.
63
+ */
64
+ fs?: FsDriver;
65
+ }
66
+ /** A mount stated by what it is: no host path, nothing to bind. */
67
+ export type LogicalMount = Required<Omit<Mount, 'host'>>;
68
+ /**
69
+ * The mounts a supplying backend is asked for. The skill catalog arrives here
70
+ * as one bind per skill folder — podman cannot merge directories — and leaves
71
+ * as the single `/skills` tree it is to the agent. A mount nobody labelled is a
72
+ * host directory with no meaning elsewhere, so it is refused rather than
73
+ * silently dropped.
74
+ */
75
+ export declare function logicalMounts(mounts: readonly Mount[]): LogicalMount[];
76
+ /**
77
+ * The file tools' view for a sandbox that keeps its files with the container
78
+ * rather than reading host directories: the workspace is its mount point, and
79
+ * each tree is found where the container has it — so a path the model writes
80
+ * means one file to the tools and the shell. What a remote `Sandbox.files`
81
+ * returns; a podman one reads the host paths as they are.
82
+ */
83
+ export declare function inSandboxPaths(opts: FilesView, fs: FsDriver): FilesView;
84
+ //# sourceMappingURL=mounts.d.ts.map
package/dist/mounts.js ADDED
@@ -0,0 +1,63 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The trees an agent can reach, and what each one is
3
+ //
4
+ // One directory is one mount, and the host hands the same array to the file
5
+ // tools and to the sandbox so that both call it by the same name: a path a command
6
+ // prints is a path `read_file` accepts.
7
+ //
8
+ // On `zen run` every tree is a host directory bound into the container, and
9
+ // `host` is the whole story. A sandbox on another machine has no host path to
10
+ // bind: it has to be told what the tree is — the project's assets, its skills —
11
+ // and fetch that itself. So a mount carries both: `host` for a backend that
12
+ // binds, `source` for one that supplies. Memory is not a mount: its files stay
13
+ // with the graph and reach a sandbox only through `memory_copy_file`.
14
+ // ---------------------------------------------------------------------------
15
+ /** Where the workspace appears in the agent's namespace, and the default cwd. */
16
+ export const WORKSPACE_MOUNT = '/workspace';
17
+ /** Where a project's `assets/` directory appears. */
18
+ export const ASSETS_MOUNT = '/assets';
19
+ /** ...and where its skill catalog does, so a skill's scripts can be run. */
20
+ export const SKILLS_MOUNT = '/skills';
21
+ /**
22
+ * The mounts a supplying backend is asked for. The skill catalog arrives here
23
+ * as one bind per skill folder — podman cannot merge directories — and leaves
24
+ * as the single `/skills` tree it is to the agent. A mount nobody labelled is a
25
+ * host directory with no meaning elsewhere, so it is refused rather than
26
+ * silently dropped.
27
+ */
28
+ export function logicalMounts(mounts) {
29
+ const out = [];
30
+ let skills = false;
31
+ for (const m of mounts) {
32
+ if (!m.source) {
33
+ throw new Error(`mount ${m.at} has no source, so only a local sandbox can provide it`);
34
+ }
35
+ if (m.source.kind === 'skills') {
36
+ if (!skills) {
37
+ out.push({ at: SKILLS_MOUNT, readOnly: true, source: m.source });
38
+ skills = true;
39
+ }
40
+ continue;
41
+ }
42
+ out.push({ at: m.at, readOnly: m.readOnly ?? false, source: m.source });
43
+ }
44
+ return out;
45
+ }
46
+ /**
47
+ * The file tools' view for a sandbox that keeps its files with the container
48
+ * rather than reading host directories: the workspace is its mount point, and
49
+ * each tree is found where the container has it — so a path the model writes
50
+ * means one file to the tools and the shell. What a remote `Sandbox.files`
51
+ * returns; a podman one reads the host paths as they are.
52
+ */
53
+ export function inSandboxPaths(opts, fs) {
54
+ const mount = opts.mount ?? WORKSPACE_MOUNT;
55
+ return {
56
+ ...opts,
57
+ root: mount,
58
+ mount,
59
+ mounts: logicalMounts(opts.mounts ?? []).map((m) => ({ ...m, host: m.at })),
60
+ fs,
61
+ };
62
+ }
63
+ //# sourceMappingURL=mounts.js.map