@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/dist/podman.js ADDED
@@ -0,0 +1,555 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { posix } from 'node:path';
3
+ import { localFs } from "./fs.js";
4
+ import { sandboxLimits } from "./limits.js";
5
+ import { WORKSPACE_MOUNT } from "./mounts.js";
6
+ import { MAX_TIMEOUT_MS, resilient, runProcess } from "./process.js";
7
+ import { DEFAULT_SANDBOX_IMAGE, SandboxError, } from "./contract.js";
8
+ // ---------------------------------------------------------------------------
9
+ // The podman sandbox
10
+ //
11
+ // `Sandbox` on this machine: one podman container per spec, driven by spawning the
12
+ // engine binary. Everything here is about that engine — its argv, its exit
13
+ // codes, its lock table, the ssh hop into its virtual machine — and none of it
14
+ // is something another backend would share.
15
+ // ---------------------------------------------------------------------------
16
+ /** Where background jobs keep their logs. Inside the container, not the host. */
17
+ const JOBS = '/tmp/zenera-jobs';
18
+ /** ...and a job's log is read in windows of this many lines. */
19
+ const MAX_LOG_LINES = 400;
20
+ const DEFAULT_TIMEOUT_MS = 120_000;
21
+ const DEFAULT_PIDS = 1024;
22
+ /** How long a container gets to stop itself before it is killed. */
23
+ const STOP_GRACE_S = 2;
24
+ /** SIGKILL, which is how the kernel's out-of-memory killer arrives. */
25
+ const KILLED = 137;
26
+ // Strict mode's filesystem limits; its cores and memory are in `limits.ts`.
27
+ const STRICT_TMPFS = 'rw,noexec,nosuid,nodev';
28
+ const STRICT_TMP_SIZE = '64m';
29
+ /**
30
+ * Asked of the container after a kill. Builtins only: a slim image may have no
31
+ * `awk`, and strict hardening leaves nowhere writable to install one. `oom_kill`
32
+ * counts kills in this container's cgroup and no other, which is what separates
33
+ * the two cases. Unreadable values print empty, and the caller reads that as
34
+ * "could not tell".
35
+ */
36
+ const CGROUP = [
37
+ `peak=; kills=`,
38
+ `read peak < /sys/fs/cgroup/memory.peak 2>/dev/null`,
39
+ `while read -r k v; do [ "$k" = oom_kill ] && kills=$v; done < /sys/fs/cgroup/memory.events 2>/dev/null`,
40
+ `echo "peak=$peak kills=$kills"`,
41
+ ].join('\n');
42
+ /**
43
+ * The container, started when it is first needed and not before. A project
44
+ * whose agents never shell out never pays for an image pull, and a session
45
+ * that only ever asks a question never leaves anything behind.
46
+ */
47
+ export class PodmanSandbox {
48
+ name;
49
+ /** the container binds the host directories, so the disk is where the files are */
50
+ fs = localFs;
51
+ spec;
52
+ engine;
53
+ #run;
54
+ #jobs = new Map();
55
+ #ready;
56
+ #created = false;
57
+ /** kills counted in this container's cgroup as of the last probe */
58
+ #kills;
59
+ constructor(opts, host = {}) {
60
+ this.spec = resolveSpec(opts);
61
+ this.engine = host.engine ?? 'podman';
62
+ this.#run = resilient(host.exec ?? runProcess);
63
+ this.name = containerName(this.spec);
64
+ }
65
+ /** Whether anything was actually started — `close` is free when not. */
66
+ get started() {
67
+ return this.#created;
68
+ }
69
+ /** The host paths, as they are: the container binds exactly these. */
70
+ files(opts) {
71
+ return { ...opts, fs: this.fs };
72
+ }
73
+ /**
74
+ * Idempotent, and single-flight: several tool calls in one batch all await
75
+ * the same start rather than racing to create the same container.
76
+ */
77
+ start() {
78
+ this.#ready ??= this.#bring().catch((err) => {
79
+ // A failed start must not be remembered as a start, or every later
80
+ // call in the session inherits a failure it could have retried.
81
+ this.#ready = undefined;
82
+ throw err;
83
+ });
84
+ return this.#ready;
85
+ }
86
+ async #bring() {
87
+ const status = await this.#podman([
88
+ 'container',
89
+ 'inspect',
90
+ this.name,
91
+ '--format',
92
+ '{{.State.Status}}',
93
+ ]);
94
+ if (status.code === 0) {
95
+ const state = status.stdout.trim();
96
+ if (state !== 'running') {
97
+ await this.#must(['start', this.name], `could not start container ${this.name}`);
98
+ }
99
+ this.#created = true;
100
+ return;
101
+ }
102
+ const made = await this.#podman(this.#createArgs());
103
+ if (made.code !== 0) {
104
+ throw new SandboxError(`could not create container ${this.name}: ${message(made)}`, LOCKS.test(made.stderr) ? locksHint(this.engine) : undefined);
105
+ }
106
+ this.#created = true;
107
+ }
108
+ #createArgs() {
109
+ const s = this.spec;
110
+ const args = ['run', '--detach', '--name', this.name];
111
+ args.push('--label', 'zenera=1', '--label', `zenera.key=${s.key}`);
112
+ args.push('--volume', mount({ host: s.root, at: s.workdir, readOnly: s.readOnly }));
113
+ for (const m of s.mounts) {
114
+ args.push('--volume', mount(m));
115
+ }
116
+ args.push('--workdir', s.workdir);
117
+ args.push('--network', s.network);
118
+ if (s.cpus > 0) {
119
+ args.push('--cpus', String(s.cpus));
120
+ }
121
+ if (s.memory > 0) {
122
+ args.push('--memory', `${s.memory}m`);
123
+ }
124
+ args.push('--pids-limit', String(DEFAULT_PIDS));
125
+ // `no-new-privileges` costs nothing here and closes setuid escalation.
126
+ // Under `standard` the capabilities are deliberately *not* dropped
127
+ // wholesale: installing a package is the ordinary use of this tool, and
128
+ // `--cap-drop=ALL` breaks every package manager there is. The container
129
+ // is the boundary. `strict` makes the opposite trade, below.
130
+ args.push('--security-opt', 'no-new-privileges');
131
+ // Background jobs orphan processes; without an init they accumulate as
132
+ // zombies until the pids limit stops the sandbox dead.
133
+ args.push('--init');
134
+ args.push('--restart', 'no');
135
+ if (s.hardening === 'strict') {
136
+ args.push('--cap-drop', 'ALL');
137
+ // The image's own files become reference material: a command can
138
+ // read a binary and run it, and can write nowhere but the mounts
139
+ // and the tmpfs below.
140
+ args.push('--read-only');
141
+ // `noexec` is safe for the job launcher because a job script is
142
+ // *interpreted* (`/bin/sh <file>`), never executed as a program.
143
+ // `/run` is left to podman's own read-only tmpfs: `--init` mounts
144
+ // catatonit under it and has to be able to exec it.
145
+ args.push('--tmpfs', `/tmp:${STRICT_TMPFS},size=${STRICT_TMP_SIZE}`);
146
+ if (!s.user) {
147
+ // Rootless podman maps container uid 0 to the host account, so
148
+ // the default container root is already unprivileged on the
149
+ // host — but it is uid 0 *inside*, where the setuid binaries
150
+ // are. `keep-id` runs the process as the host's own uid, which
151
+ // is both non-zero and the owner of every bind mount, so the
152
+ // workspace stays writable without a chown.
153
+ args.push('--userns', 'keep-id');
154
+ }
155
+ }
156
+ if (s.user) {
157
+ args.push('--user', s.user);
158
+ }
159
+ for (const [k, v] of Object.entries(s.env)) {
160
+ args.push('--env', `${k}=${v}`);
161
+ }
162
+ // No `=`, and so no value: podman reads each one from its own
163
+ // environment. A key passed this way is not in the argv of a process
164
+ // any user on the machine can list.
165
+ for (const name of s.secrets) {
166
+ if (process.env[name]) {
167
+ args.push('--env', name);
168
+ }
169
+ }
170
+ args.push(s.image, 'sleep', 'infinity');
171
+ return args;
172
+ }
173
+ /**
174
+ * `podman exec` for a command the agent asked for, with the run's own
175
+ * environment. The script still arrives on stdin; when a path has to be
176
+ * extended, the shell that reads it is started by a fixed `-c` line that
177
+ * exports the variable and `exec`s it — built from host-chosen names and
178
+ * paths only, so no text of the agent's is ever part of a parse here.
179
+ */
180
+ #exec(options = []) {
181
+ const env = Object.entries(this.spec.execEnv).flatMap(([k, v]) => ['--env', `${k}=${v}`]);
182
+ const paths = Object.entries(this.spec.execPaths).filter(([, dirs]) => dirs.length);
183
+ const shell = paths.length
184
+ ? [
185
+ '/bin/sh',
186
+ '-c',
187
+ [
188
+ ...paths.map(([name, dirs]) => `export ${name}=${quote(dirs.join(':'))}"\${${name}:+:\$${name}}"`),
189
+ 'exec /bin/sh -s',
190
+ ].join('; '),
191
+ ]
192
+ : ['/bin/sh', '-s'];
193
+ return ['exec', '--interactive', ...env, ...options, this.name, ...shell];
194
+ }
195
+ /**
196
+ * Runs one command and waits for it. The script goes down stdin, so no part
197
+ * of it is ever parsed by a shell on this side of the container.
198
+ */
199
+ async exec(command, opts = {}) {
200
+ await this.start();
201
+ const cwd = this.inside(opts.cwd);
202
+ const timeoutMs = this.#timeout(opts.timeout);
203
+ const startedAt = Date.now();
204
+ const once = () => this.#run(this.engine, this.#exec(['--workdir', cwd]), {
205
+ input: command,
206
+ timeoutMs,
207
+ signal: opts.signal,
208
+ });
209
+ let res = await once();
210
+ if (gone(res)) {
211
+ // podman refused before running anything, so the retry cannot run the command twice.
212
+ await this.#revive(res);
213
+ res = await once();
214
+ }
215
+ return {
216
+ exit_code: res.code,
217
+ stdout: res.stdout,
218
+ stderr: res.stderr,
219
+ truncated: res.truncated || undefined,
220
+ timed_out: res.timedOut || undefined,
221
+ duration_ms: Date.now() - startedAt,
222
+ out_of_memory: await this.#oom(res.code, res.timedOut, opts.signal),
223
+ };
224
+ }
225
+ /**
226
+ * Starts a command and returns. The wrapper writes the log and the exit
227
+ * code to files the container owns, which is what makes the job readable
228
+ * after the `exec` that started it has long returned.
229
+ */
230
+ async startJob(command, opts = {}) {
231
+ await this.start();
232
+ const cwd = this.inside(opts.cwd);
233
+ const id = jobId();
234
+ // The command reaches the container as a *file*, written by `cat` from
235
+ // stdin. Interpolating it into the launcher script would put user text
236
+ // back into a shell parse, which is the one thing this design avoids.
237
+ const body = await this.#run(this.engine, [
238
+ 'exec',
239
+ '--interactive',
240
+ this.name,
241
+ '/bin/sh',
242
+ '-c',
243
+ `mkdir -p ${JOBS} && cat > ${JOBS}/${id}.sh`,
244
+ ], { input: command, timeoutMs: 30_000 });
245
+ if (body.code !== 0) {
246
+ throw new SandboxError(`could not write the job: ${message(body)}`);
247
+ }
248
+ // One logical line, because a newline would end the group command and
249
+ // leave the redirection and the `&` on a statement of their own — the
250
+ // job would then run in the foreground of this `exec`, with its output
251
+ // going to the caller instead of the log.
252
+ //
253
+ // `setsid` puts the job in a session of its own so that stopping it can
254
+ // signal a process group rather than one process; a shell without job
255
+ // control leaves background jobs in its own group, and signalling that
256
+ // would take down the `exec` shell with them. It is not required: where
257
+ // it is missing the job still runs, and stopping falls back to the
258
+ // single pid. The leader writes its own pid, since with `setsid` the
259
+ // launching shell's `$!` is no longer the process to signal.
260
+ const inner = `echo $$ > ${JOBS}/${id}.pid; ` +
261
+ `/bin/sh ${JOBS}/${id}.sh; echo $? > ${JOBS}/${id}.exit`;
262
+ const launch = [
263
+ `cd ${quote(cwd)}`,
264
+ `S=; command -v setsid > /dev/null 2>&1 && S=setsid`,
265
+ `$S /bin/sh -c ${quote(inner)} > ${JOBS}/${id}.log 2>&1 < /dev/null &`,
266
+ // The pid file is written by the child, so the launcher waits for
267
+ // it: a read or a stop arriving immediately after must not find
268
+ // the job missing.
269
+ `i=0; while [ ! -s ${JOBS}/${id}.pid ] && [ $i -lt 50 ]; do i=$((i+1)); sleep 0.1; done`,
270
+ ].join('\n');
271
+ const started = await this.#run(this.engine, this.#exec(), {
272
+ input: launch,
273
+ timeoutMs: 30_000,
274
+ });
275
+ if (started.code !== 0) {
276
+ throw new SandboxError(`could not start the job: ${message(started)}`);
277
+ }
278
+ const job = { id, command, cwd, startedAt: Date.now() };
279
+ this.#jobs.set(id, job);
280
+ return id;
281
+ }
282
+ /** A window of a job's log, plus whether it is still going. */
283
+ async readJob(id, from = 1) {
284
+ const job = this.#job(id);
285
+ await this.start();
286
+ const first = Math.max(1, Math.trunc(from));
287
+ const script = [
288
+ `cd ${JOBS} || exit 0`,
289
+ `printf 'lines=%s\\n' "$(wc -l < ${id}.log 2>/dev/null || echo 0)"`,
290
+ `if [ -f ${id}.exit ]; then printf 'exit=%s\\n' "$(cat ${id}.exit)"; fi`,
291
+ `printf -- '---\\n'`,
292
+ `tail -n +${first} ${id}.log 2>/dev/null | head -n ${MAX_LOG_LINES}`,
293
+ ].join('\n');
294
+ const res = await this.#run(this.engine, ['exec', '--interactive', this.name, '/bin/sh', '-s'], { input: script, timeoutMs: 30_000 });
295
+ const [head, ...rest] = res.stdout.split('\n---\n');
296
+ const meta = Object.fromEntries(head
297
+ .split('\n')
298
+ .filter(Boolean)
299
+ .map((l) => l.split('=', 2)));
300
+ const output = rest.join('\n---\n');
301
+ const lines = Number(meta.lines ?? 0);
302
+ const shown = output ? output.replace(/\n$/, '').split('\n').length : 0;
303
+ const exit = meta.exit === undefined ? undefined : Number(meta.exit);
304
+ // Kept on the job: a second read would find the kill counter unchanged
305
+ // and report this container's own overrun as the machine's doing.
306
+ if (exit === KILLED && !job.oom) {
307
+ job.oom = await this.#oom(exit, false);
308
+ }
309
+ return {
310
+ job_id: id,
311
+ command: job.command,
312
+ running: meta.exit === undefined,
313
+ exit_code: exit,
314
+ duration_ms: Date.now() - job.startedAt,
315
+ start_line: first,
316
+ end_line: first + shown - 1,
317
+ lines,
318
+ truncated: first + shown - 1 < lines || undefined,
319
+ output,
320
+ out_of_memory: job.oom,
321
+ };
322
+ }
323
+ /** Signals a job's whole process group, so its children go too. */
324
+ async stopJob(id) {
325
+ this.#job(id);
326
+ await this.start();
327
+ const script = [
328
+ `pid=$(cat ${JOBS}/${id}.pid 2>/dev/null)`,
329
+ `[ -n "$pid" ] || { echo "already gone"; exit 0; }`,
330
+ `kill -TERM -"$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true`,
331
+ `sleep 1`,
332
+ `kill -KILL -"$pid" 2>/dev/null || true`,
333
+ `echo stopped`,
334
+ ].join('\n');
335
+ const res = await this.#run(this.engine, ['exec', '--interactive', this.name, '/bin/sh', '-s'], { input: script, timeoutMs: 30_000 });
336
+ this.#jobs.delete(id);
337
+ return { job_id: id, stopped: res.code === 0 };
338
+ }
339
+ #job(id) {
340
+ const job = this.#jobs.get(id);
341
+ if (!job) {
342
+ throw new SandboxError(`no such job: ${id}`, 'ids come from run_command_background');
343
+ }
344
+ return job;
345
+ }
346
+ /**
347
+ * Stops a persisted container and removes an ephemeral one. Stopping
348
+ * rather than removing is what makes `persist` mean anything: a stopped
349
+ * container costs disk, a running one costs the machine's whole budget.
350
+ */
351
+ async close() {
352
+ if (!this.#created) {
353
+ return;
354
+ }
355
+ this.#created = false;
356
+ this.#ready = undefined;
357
+ await this.#podman(this.spec.persist
358
+ ? ['stop', '--time', String(STOP_GRACE_S), this.name]
359
+ : ['rm', '--force', '--volumes', this.name]).catch(() => undefined);
360
+ }
361
+ /**
362
+ * A path inside the container, contained to the working directory. Purely
363
+ * lexical, and correctly so: this is the container's path space, not this
364
+ * machine's, and there is nothing here to resolve a symlink against.
365
+ */
366
+ inside(path) {
367
+ const base = this.spec.workdir;
368
+ if (!path) {
369
+ return base;
370
+ }
371
+ if (path.includes('\0')) {
372
+ throw new SandboxError('path contains a null byte');
373
+ }
374
+ const at = posix.resolve(base, path);
375
+ if (at !== base && !at.startsWith(`${base}/`)) {
376
+ throw new SandboxError(`outside ${base}: ${path}`, `paths are relative to ${base}`);
377
+ }
378
+ return at;
379
+ }
380
+ #timeout(seconds) {
381
+ const wanted = seconds && seconds > 0 ? seconds * 1000 : this.spec.timeout * 1000;
382
+ return Math.min(wanted || DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
383
+ }
384
+ /**
385
+ * Turns a bare 137 into a reason, by asking the container which cgroup did
386
+ * the killing. Only a kill counted against *this* container is the caller's
387
+ * to fix; the rest is the machine being busy, and telling someone to shrink
388
+ * a command that already fit would send them off after the wrong thing.
389
+ */
390
+ async #oom(code, timedOut, signal) {
391
+ // A timeout or a cancel kills podman on this side, not the container,
392
+ // and an uncapped container has no limit it could have exceeded.
393
+ if (code !== KILLED || timedOut || signal?.aborted || this.spec.memory <= 0) {
394
+ return undefined;
395
+ }
396
+ const res = await this.#run(this.engine, ['exec', '--interactive', this.name, '/bin/sh', '-s'], { input: CGROUP, timeoutMs: 15_000 }).catch(() => undefined);
397
+ const out = res?.stdout ?? '';
398
+ const peak = Number(/peak=(\d+)/.exec(out)?.[1]);
399
+ const kills = Number(/kills=(\d+)/.exec(out)?.[1]);
400
+ const counted = Number.isFinite(kills);
401
+ // Nothing readable is read as our own doing: that costs one wasted
402
+ // retry, where the other guess costs an agent retrying forever a
403
+ // command that was never going to fit.
404
+ const own = !counted || kills > (this.#kills ?? 0);
405
+ if (counted) {
406
+ this.#kills = kills;
407
+ }
408
+ const limit = this.spec.memory;
409
+ return {
410
+ limit_mib: limit,
411
+ peak_mib: Number.isFinite(peak) ? Math.round(peak / (1024 * 1024)) : undefined,
412
+ retryable: !own,
413
+ hint: own
414
+ ? `this container is limited to ${limit} MiB and the command wanted more. ` +
415
+ `Running it again unchanged will end the same way: give the sandbox more ` +
416
+ `memory in the project configuration, or do the work in smaller pieces.`
417
+ : `the machine ran out of memory while this container was inside its ` +
418
+ `${limit} MiB limit, so it was killed for something another container did. ` +
419
+ `The command itself does not need to change, and may work once the machine ` +
420
+ `is less busy.`,
421
+ };
422
+ }
423
+ #podman(args) {
424
+ return this.#run(this.engine, args, { timeoutMs: 120_000 });
425
+ }
426
+ /** The container stopped or vanished under us (machine slept, restarted, or someone removed it). */
427
+ async #revive(res) {
428
+ this.#ready = undefined;
429
+ try {
430
+ await this.start();
431
+ }
432
+ catch (err) {
433
+ throw new SandboxError(`the sandbox container ${this.name} stopped (${message(res)}) and could not be restarted: ` +
434
+ (err instanceof Error ? err.message : String(err)), `the ${this.engine} machine may have stopped or restarted — check \`${this.engine} machine list\`, ` +
435
+ `start it with \`${this.engine} machine start\`, then retry`);
436
+ }
437
+ }
438
+ async #must(args, what) {
439
+ const res = await this.#podman(args);
440
+ if (res.code !== 0) {
441
+ throw new SandboxError(`${what}: ${message(res)}`);
442
+ }
443
+ return res;
444
+ }
445
+ }
446
+ function mount(m) {
447
+ return `${m.host}:${m.at}${m.readOnly ? ':ro' : ''}`;
448
+ }
449
+ function message(res) {
450
+ return (res.stderr.trim() || res.stdout.trim() || `exit ${res.code}`).split('\n')[0];
451
+ }
452
+ /** podman's words for "every lock is taken": `allocating lock … exceeded num_locks (2048)`. */
453
+ const LOCKS = /exceeded num_locks|allocating lock/i;
454
+ /**
455
+ * A stopped container holds a lock as surely as a running one, and a
456
+ * persisted sandbox is stopped, never removed — so they pile up one per
457
+ * session until the engine can create nothing at all, for anyone.
458
+ */
459
+ function locksHint(engine) {
460
+ return (`the ${engine} engine has no locks left: every container it holds, stopped ones ` +
461
+ `included, takes one. Remove the stopped sandbox containers with ` +
462
+ `\`${engine} container prune --force --filter label=zenera=1\`, then retry`);
463
+ }
464
+ /** podman's own exit code for "could not exec at all", with the two reasons that mean the container is not there to exec in. */
465
+ function gone(res) {
466
+ return (res.code === 125 &&
467
+ /can only create exec sessions on running containers|container state improper|no such container/i.test(res.stderr));
468
+ }
469
+ /** Only ever applied to paths this module derived, never to a model's text. */
470
+ function quote(s) {
471
+ return `'${s.replaceAll("'", `'\\''`)}'`;
472
+ }
473
+ function jobId() {
474
+ return `job-${createHash('sha256').update(`${Date.now()}${Math.random()}`).digest('hex').slice(0, 6)}`;
475
+ }
476
+ function resolveSpec(opts) {
477
+ const workdir = opts.workdir ?? WORKSPACE_MOUNT;
478
+ if (!posix.isAbsolute(workdir)) {
479
+ throw new SandboxError(`sandbox workdir must be absolute: ${workdir}`);
480
+ }
481
+ const limits = sandboxLimits(opts);
482
+ const { hardening } = limits;
483
+ const strict = hardening === 'strict';
484
+ for (const name of [...Object.keys(opts.execEnv ?? {}), ...Object.keys(opts.execPaths ?? {})]) {
485
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
486
+ throw new SandboxError(`not a variable name: ${JSON.stringify(name)}`);
487
+ }
488
+ }
489
+ return {
490
+ root: opts.root,
491
+ key: opts.key,
492
+ image: opts.image ?? DEFAULT_SANDBOX_IMAGE,
493
+ cpus: limits.cpus,
494
+ memory: limits.memory,
495
+ explicitCpus: opts.cpus !== undefined,
496
+ explicitMemory: opts.memory !== undefined,
497
+ network: opts.network ?? (strict ? 'none' : 'bridge'),
498
+ workdir,
499
+ timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS / 1000,
500
+ hardening,
501
+ user: opts.user,
502
+ persist: opts.persist ?? false,
503
+ env: opts.env ?? {},
504
+ secrets: opts.secrets ?? [],
505
+ readOnly: opts.readOnly ?? false,
506
+ mounts: opts.mounts ?? [],
507
+ execEnv: opts.execEnv ?? {},
508
+ execPaths: opts.execPaths ?? {},
509
+ };
510
+ }
511
+ /**
512
+ * The container's name is a function of its configuration. Change the image
513
+ * and a new container appears rather than an old one quietly persisting with
514
+ * the wrong rootfs — which is the failure mode `persist` would otherwise have.
515
+ *
516
+ * Secrets contribute their names and not their values: what a container is
517
+ * depends on which credentials reach it, not on what they happen to say today,
518
+ * and hashing the values would throw away a persisted container's filesystem
519
+ * every time a key was rotated.
520
+ */
521
+ function containerName(spec) {
522
+ // Unconfigured cpus and memory hash 0 under standard and strict's own
523
+ // defaults under strict, so introducing defaults did not rename every
524
+ // existing container.
525
+ const strict = spec.hardening === 'strict' ? sandboxLimits({ hardening: 'strict' }) : undefined;
526
+ const hashCpus = spec.explicitCpus ? spec.cpus : (strict?.cpus ?? 0);
527
+ const hashMemory = spec.explicitMemory ? spec.memory : (strict?.memory ?? 0);
528
+ const digest = createHash('sha256')
529
+ .update(JSON.stringify([
530
+ spec.root,
531
+ spec.image,
532
+ hashCpus,
533
+ hashMemory,
534
+ spec.network,
535
+ spec.workdir,
536
+ spec.user ?? null,
537
+ spec.readOnly,
538
+ spec.mounts,
539
+ Object.entries(spec.env).sort(),
540
+ [...spec.secrets].sort(),
541
+ // Appended only when it is set, so adding the profile did not
542
+ // rename every container that existed before it.
543
+ ...(spec.hardening === 'strict' ? ['strict'] : []),
544
+ ]))
545
+ .digest('hex')
546
+ .slice(0, 10);
547
+ const key = spec.key
548
+ .toLowerCase()
549
+ .replace(/[^a-z0-9]+/g, '-')
550
+ .replace(/^-|-$/g, '');
551
+ return `zn-${key || 'session'}-${digest}`;
552
+ }
553
+ /** Podman on this machine: what `zen run` uses, and the engine's default. */
554
+ export const podmanSandbox = (opts) => new PodmanSandbox(opts);
555
+ //# sourceMappingURL=podman.js.map
@@ -0,0 +1,29 @@
1
+ /** Nothing runs longer than this, whatever it asked for. */
2
+ export declare const MAX_TIMEOUT_MS = 3600000;
3
+ export interface ProcOptions {
4
+ /** written to stdin, which is then closed */
5
+ input?: string;
6
+ timeoutMs?: number;
7
+ signal?: AbortSignal;
8
+ /** per stream */
9
+ maxBytes?: number;
10
+ }
11
+ export interface ProcResult {
12
+ code: number;
13
+ stdout: string;
14
+ stderr: string;
15
+ truncated: boolean;
16
+ timedOut: boolean;
17
+ }
18
+ export type Runner = (bin: string, args: readonly string[], opts?: ProcOptions) => Promise<ProcResult>;
19
+ export declare const runProcess: Runner;
20
+ /**
21
+ * `run`, retried while the engine could not be reached at all.
22
+ *
23
+ * Empty stdout is the guard that keeps this from retrying real work: a command
24
+ * that produced output ran, whatever its stderr says afterwards. The wait is
25
+ * jittered because the burst being backed off from is N clients arriving
26
+ * together, and a fixed delay would only reassemble it.
27
+ */
28
+ export declare function resilient(run: Runner, attempts?: number): Runner;
29
+ //# sourceMappingURL=process.d.ts.map