@zenera/neo 1.1.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.
Files changed (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +178 -0
  3. package/dist/agent.d.ts +66 -0
  4. package/dist/agent.js +97 -0
  5. package/dist/architecture.d.ts +74 -0
  6. package/dist/architecture.js +105 -0
  7. package/dist/embedding.d.ts +60 -0
  8. package/dist/embedding.js +21 -0
  9. package/dist/embeddings/gemini.d.ts +24 -0
  10. package/dist/embeddings/gemini.js +71 -0
  11. package/dist/embeddings/index.d.ts +4 -0
  12. package/dist/embeddings/index.js +4 -0
  13. package/dist/embeddings/openai.d.ts +18 -0
  14. package/dist/embeddings/openai.js +27 -0
  15. package/dist/embeddings/openrouter.d.ts +21 -0
  16. package/dist/embeddings/openrouter.js +54 -0
  17. package/dist/events.d.ts +144 -0
  18. package/dist/events.js +110 -0
  19. package/dist/ids.d.ts +14 -0
  20. package/dist/ids.js +33 -0
  21. package/dist/index.d.ts +25 -0
  22. package/dist/index.js +25 -0
  23. package/dist/inspect/client.d.ts +2 -0
  24. package/dist/inspect/client.js +1326 -0
  25. package/dist/inspect/css.d.ts +2 -0
  26. package/dist/inspect/css.js +164 -0
  27. package/dist/inspect/index.d.ts +9 -0
  28. package/dist/inspect/index.js +12 -0
  29. package/dist/inspect/page.d.ts +3 -0
  30. package/dist/inspect/page.js +93 -0
  31. package/dist/inspect/report.d.ts +54 -0
  32. package/dist/inspect/report.js +62 -0
  33. package/dist/kernel.d.ts +178 -0
  34. package/dist/kernel.js +929 -0
  35. package/dist/memory-stores/factory.d.ts +15 -0
  36. package/dist/memory-stores/factory.js +29 -0
  37. package/dist/memory-stores/file.d.ts +37 -0
  38. package/dist/memory-stores/file.js +174 -0
  39. package/dist/memory-stores/in-memory.d.ts +17 -0
  40. package/dist/memory-stores/in-memory.js +80 -0
  41. package/dist/memory-stores/index.d.ts +4 -0
  42. package/dist/memory-stores/index.js +5 -0
  43. package/dist/memory.d.ts +115 -0
  44. package/dist/memory.js +213 -0
  45. package/dist/model.d.ts +25 -0
  46. package/dist/model.js +2 -0
  47. package/dist/models/anthropic.d.ts +36 -0
  48. package/dist/models/anthropic.js +293 -0
  49. package/dist/models/factory.d.ts +202 -0
  50. package/dist/models/factory.js +533 -0
  51. package/dist/models/gemini.d.ts +45 -0
  52. package/dist/models/gemini.js +368 -0
  53. package/dist/models/index.d.ts +7 -0
  54. package/dist/models/index.js +7 -0
  55. package/dist/models/openai-chat.d.ts +19 -0
  56. package/dist/models/openai-chat.js +203 -0
  57. package/dist/models/openai-responses.d.ts +31 -0
  58. package/dist/models/openai-responses.js +258 -0
  59. package/dist/models/openrouter.d.ts +44 -0
  60. package/dist/models/openrouter.js +230 -0
  61. package/dist/payload-stores/factory.d.ts +15 -0
  62. package/dist/payload-stores/factory.js +30 -0
  63. package/dist/payload-stores/file.d.ts +29 -0
  64. package/dist/payload-stores/file.js +112 -0
  65. package/dist/payload-stores/in-memory.d.ts +17 -0
  66. package/dist/payload-stores/in-memory.js +40 -0
  67. package/dist/payload-stores/index.d.ts +4 -0
  68. package/dist/payload-stores/index.js +5 -0
  69. package/dist/payload.d.ts +89 -0
  70. package/dist/payload.js +181 -0
  71. package/dist/project/config.d.ts +302 -0
  72. package/dist/project/config.js +290 -0
  73. package/dist/project/index.d.ts +4 -0
  74. package/dist/project/index.js +4 -0
  75. package/dist/project/load.d.ts +142 -0
  76. package/dist/project/load.js +430 -0
  77. package/dist/project/refs.d.ts +23 -0
  78. package/dist/project/refs.js +70 -0
  79. package/dist/prompt.d.ts +66 -0
  80. package/dist/prompt.js +62 -0
  81. package/dist/runner.d.ts +103 -0
  82. package/dist/runner.js +537 -0
  83. package/dist/services.d.ts +23 -0
  84. package/dist/services.js +44 -0
  85. package/dist/skill-providers/factory.d.ts +16 -0
  86. package/dist/skill-providers/factory.js +26 -0
  87. package/dist/skill-providers/file.d.ts +44 -0
  88. package/dist/skill-providers/file.js +209 -0
  89. package/dist/skill-providers/index.d.ts +4 -0
  90. package/dist/skill-providers/index.js +5 -0
  91. package/dist/skill-providers/static.d.ts +13 -0
  92. package/dist/skill-providers/static.js +47 -0
  93. package/dist/skills.d.ts +102 -0
  94. package/dist/skills.js +232 -0
  95. package/dist/state.d.ts +86 -0
  96. package/dist/state.js +49 -0
  97. package/dist/tools/exa.d.ts +23 -0
  98. package/dist/tools/exa.js +389 -0
  99. package/dist/tools/index.d.ts +4 -0
  100. package/dist/tools/index.js +6 -0
  101. package/dist/tools/sandbox.d.ts +172 -0
  102. package/dist/tools/sandbox.js +602 -0
  103. package/dist/tools/workspace.d.ts +53 -0
  104. package/dist/tools/workspace.js +1034 -0
  105. package/dist/trajectory.d.ts +213 -0
  106. package/dist/trajectory.js +232 -0
  107. package/dist/types.d.ts +195 -0
  108. package/dist/types.js +134 -0
  109. package/package.json +98 -0
@@ -0,0 +1,602 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { posix } from 'node:path';
4
+ import { tool } from "../types.js";
5
+ // ---------------------------------------------------------------------------
6
+ // Sandbox tools
7
+ //
8
+ // A shell, and a boundary around it. The boundary is a container: the agent's
9
+ // commands run against an image the project chose, with one host directory
10
+ // bind-mounted at `/workspace` and nothing else of the machine in reach. That
11
+ // is the whole security story, and it is deliberately the *only* one — the
12
+ // command string is handed to `/bin/sh` verbatim, because escaping a shell
13
+ // language is a game nobody wins and the container is what makes losing it
14
+ // survivable.
15
+ //
16
+ // The host side is the opposite: every process is spawned with an argv array
17
+ // and `shell: false`, and the command text travels on **stdin** rather than in
18
+ // an argument, so there is no host shell to inject into and no argv length to
19
+ // overflow.
20
+ //
21
+ // Nothing here knows how to install a container engine or start a virtual
22
+ // machine. That is a host concern with a user attached to it, so it lives in
23
+ // whatever front end is driving this — the library only reports, clearly, that
24
+ // the engine did not answer.
25
+ // ---------------------------------------------------------------------------
26
+ /** Where the workspace appears inside the container. */
27
+ export const SANDBOX_MOUNT = '/workspace';
28
+ /** Where background jobs keep their logs. Inside the container, not the host. */
29
+ const JOBS = '/tmp/zenera-jobs';
30
+ /** One command's output is capped, so a `cat` of a log file cannot fill the context. */
31
+ const MAX_OUTPUT = 64 * 1024;
32
+ /** ...and a job's log is read in windows of this many lines. */
33
+ const MAX_LOG_LINES = 400;
34
+ const DEFAULT_IMAGE = 'docker.io/library/python:3.14-slim-bookworm';
35
+ /** The image a project gets when it does not name one. */
36
+ export const DEFAULT_SANDBOX_IMAGE = DEFAULT_IMAGE;
37
+ const DEFAULT_TIMEOUT_MS = 120_000;
38
+ const MAX_TIMEOUT_MS = 3_600_000;
39
+ const DEFAULT_PIDS = 1024;
40
+ /** How long a container gets to stop itself before it is killed. */
41
+ const STOP_GRACE_S = 2;
42
+ /** The family name config selects the whole set by: `tools: [sandbox:*]`. */
43
+ export const SANDBOX_GROUP = 'sandbox';
44
+ /** A failure the caller can do something about — a missing engine, usually. */
45
+ export class SandboxError extends Error {
46
+ hint;
47
+ constructor(message, hint) {
48
+ super(message);
49
+ this.name = 'SandboxError';
50
+ this.hint = hint;
51
+ }
52
+ }
53
+ export const runProcess = (bin, args, opts = {}) => new Promise((settle, fail) => {
54
+ const max = opts.maxBytes ?? MAX_OUTPUT;
55
+ const out = [];
56
+ const err = [];
57
+ let outSize = 0;
58
+ let errSize = 0;
59
+ let truncated = false;
60
+ let timedOut = false;
61
+ let done = false;
62
+ const child = spawn(bin, [...args], { shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
63
+ const keep = (into, size, chunk) => {
64
+ const room = max - size;
65
+ if (room <= 0) {
66
+ truncated = true;
67
+ return size;
68
+ }
69
+ if (chunk.length > room) {
70
+ truncated = true;
71
+ into.push(chunk.subarray(0, room));
72
+ return max;
73
+ }
74
+ into.push(chunk);
75
+ return size + chunk.length;
76
+ };
77
+ child.stdout.on('data', (c) => (outSize = keep(out, outSize, c)));
78
+ child.stderr.on('data', (c) => (errSize = keep(err, errSize, c)));
79
+ // SIGTERM first so a well-behaved process can flush; SIGKILL after, so
80
+ // one that ignores it cannot hold the turn open forever.
81
+ let hard;
82
+ const stop = () => {
83
+ child.kill('SIGTERM');
84
+ hard = setTimeout(() => child.kill('SIGKILL'), 2000);
85
+ hard.unref();
86
+ };
87
+ const timer = opts.timeoutMs
88
+ ? setTimeout(() => {
89
+ timedOut = true;
90
+ stop();
91
+ }, Math.min(opts.timeoutMs, MAX_TIMEOUT_MS))
92
+ : undefined;
93
+ const onAbort = () => stop();
94
+ opts.signal?.addEventListener('abort', onAbort, { once: true });
95
+ const finish = (fn) => {
96
+ if (done) {
97
+ return;
98
+ }
99
+ done = true;
100
+ clearTimeout(timer);
101
+ clearTimeout(hard);
102
+ opts.signal?.removeEventListener('abort', onAbort);
103
+ fn();
104
+ };
105
+ child.on('error', (e) => {
106
+ finish(() => fail(e.code === 'ENOENT'
107
+ ? new SandboxError(`${bin} is not installed, or not on PATH`)
108
+ : e));
109
+ });
110
+ child.on('close', (code) => {
111
+ finish(() => settle({
112
+ code: code ?? (timedOut ? 124 : 1),
113
+ stdout: Buffer.concat(out).toString('utf8'),
114
+ stderr: Buffer.concat(err).toString('utf8'),
115
+ truncated,
116
+ timedOut,
117
+ }));
118
+ });
119
+ if (opts.input !== undefined) {
120
+ child.stdin.on('error', () => {
121
+ // A command that exits before reading its script closes the
122
+ // pipe under us. That is the command's answer, not an error.
123
+ });
124
+ child.stdin.end(opts.input);
125
+ }
126
+ else {
127
+ child.stdin.end();
128
+ }
129
+ });
130
+ /**
131
+ * The container, started when it is first needed and not before. A project
132
+ * whose agents never shell out never pays for an image pull, and a session
133
+ * that only ever asks a question never leaves anything behind.
134
+ */
135
+ export class Sandbox {
136
+ name;
137
+ spec;
138
+ #run;
139
+ #jobs = new Map();
140
+ #ready;
141
+ #created = false;
142
+ constructor(opts) {
143
+ this.spec = resolveSpec(opts);
144
+ this.#run = opts.exec ?? runProcess;
145
+ this.name = containerName(this.spec);
146
+ }
147
+ /** Whether anything was actually started — `dispose` is free when not. */
148
+ get started() {
149
+ return this.#created;
150
+ }
151
+ /**
152
+ * Idempotent, and single-flight: several tool calls in one batch all await
153
+ * the same start rather than racing to create the same container.
154
+ */
155
+ start() {
156
+ this.#ready ??= this.#bring().catch((err) => {
157
+ // A failed start must not be remembered as a start, or every later
158
+ // call in the session inherits a failure it could have retried.
159
+ this.#ready = undefined;
160
+ throw err;
161
+ });
162
+ return this.#ready;
163
+ }
164
+ async #bring() {
165
+ const status = await this.#podman([
166
+ 'container',
167
+ 'inspect',
168
+ this.name,
169
+ '--format',
170
+ '{{.State.Status}}',
171
+ ]);
172
+ if (status.code === 0) {
173
+ const state = status.stdout.trim();
174
+ if (state !== 'running') {
175
+ await this.#must(['start', this.name], `could not start container ${this.name}`);
176
+ }
177
+ this.#created = true;
178
+ return;
179
+ }
180
+ await this.#must(this.#createArgs(), `could not create container ${this.name}`);
181
+ this.#created = true;
182
+ }
183
+ #createArgs() {
184
+ const s = this.spec;
185
+ const args = ['run', '--detach', '--name', this.name];
186
+ args.push('--label', 'zenera=1', '--label', `zenera.key=${s.key}`);
187
+ args.push('--volume', mount({ host: s.root, at: s.workdir, readOnly: s.readOnly }));
188
+ for (const m of s.mounts) {
189
+ args.push('--volume', mount(m));
190
+ }
191
+ args.push('--workdir', s.workdir);
192
+ args.push('--network', s.network);
193
+ if (s.cpus > 0) {
194
+ args.push('--cpus', String(s.cpus));
195
+ }
196
+ if (s.memory > 0) {
197
+ args.push('--memory', `${s.memory}m`);
198
+ }
199
+ args.push('--pids-limit', String(DEFAULT_PIDS));
200
+ // `no-new-privileges` costs nothing here and closes setuid escalation.
201
+ // Capabilities are deliberately *not* dropped wholesale: installing a
202
+ // package is the ordinary use of this tool, and `--cap-drop=ALL` breaks
203
+ // every package manager there is. The container is the boundary.
204
+ args.push('--security-opt', 'no-new-privileges');
205
+ // Background jobs orphan processes; without an init they accumulate as
206
+ // zombies until the pids limit stops the sandbox dead.
207
+ args.push('--init');
208
+ args.push('--restart', 'no');
209
+ if (s.user) {
210
+ args.push('--user', s.user);
211
+ }
212
+ for (const [k, v] of Object.entries(s.env)) {
213
+ args.push('--env', `${k}=${v}`);
214
+ }
215
+ args.push(s.image, 'sleep', 'infinity');
216
+ return args;
217
+ }
218
+ /**
219
+ * Runs one command and waits for it. The script goes down stdin, so no part
220
+ * of it is ever parsed by a shell on this side of the container.
221
+ */
222
+ async exec(command, opts = {}) {
223
+ await this.start();
224
+ const cwd = this.inside(opts.cwd);
225
+ const timeoutMs = this.#timeout(opts.timeout);
226
+ const startedAt = Date.now();
227
+ const res = await this.#run(this.spec.engine, ['exec', '--interactive', '--workdir', cwd, this.name, '/bin/sh', '-s'], { input: command, timeoutMs, signal: opts.signal });
228
+ return {
229
+ exit_code: res.code,
230
+ stdout: res.stdout,
231
+ stderr: res.stderr,
232
+ truncated: res.truncated || undefined,
233
+ timed_out: res.timedOut || undefined,
234
+ duration_ms: Date.now() - startedAt,
235
+ };
236
+ }
237
+ /**
238
+ * Starts a command and returns. The wrapper writes the log and the exit
239
+ * code to files the container owns, which is what makes the job readable
240
+ * after the `exec` that started it has long returned.
241
+ */
242
+ async startJob(command, opts = {}) {
243
+ await this.start();
244
+ const cwd = this.inside(opts.cwd);
245
+ const id = jobId();
246
+ // The command reaches the container as a *file*, written by `cat` from
247
+ // stdin. Interpolating it into the launcher script would put user text
248
+ // back into a shell parse, which is the one thing this design avoids.
249
+ const body = await this.#run(this.spec.engine, [
250
+ 'exec',
251
+ '--interactive',
252
+ this.name,
253
+ '/bin/sh',
254
+ '-c',
255
+ `mkdir -p ${JOBS} && cat > ${JOBS}/${id}.sh`,
256
+ ], { input: command, timeoutMs: 30_000 });
257
+ if (body.code !== 0) {
258
+ throw new SandboxError(`could not write the job: ${message(body)}`);
259
+ }
260
+ // One logical line, because a newline would end the group command and
261
+ // leave the redirection and the `&` on a statement of their own — the
262
+ // job would then run in the foreground of this `exec`, with its output
263
+ // going to the caller instead of the log.
264
+ //
265
+ // `setsid` puts the job in a session of its own so that stopping it can
266
+ // signal a process group rather than one process; a shell without job
267
+ // control leaves background jobs in its own group, and signalling that
268
+ // would take down the `exec` shell with them. It is not required: where
269
+ // it is missing the job still runs, and stopping falls back to the
270
+ // single pid. The leader writes its own pid, since with `setsid` the
271
+ // launching shell's `$!` is no longer the process to signal.
272
+ const inner = `echo $$ > ${JOBS}/${id}.pid; ` +
273
+ `/bin/sh ${JOBS}/${id}.sh; echo $? > ${JOBS}/${id}.exit`;
274
+ const launch = [
275
+ `cd ${quote(cwd)}`,
276
+ `S=; command -v setsid > /dev/null 2>&1 && S=setsid`,
277
+ `$S /bin/sh -c ${quote(inner)} > ${JOBS}/${id}.log 2>&1 < /dev/null &`,
278
+ // The pid file is written by the child, so the launcher waits for
279
+ // it: a read or a stop arriving immediately after must not find
280
+ // the job missing.
281
+ `i=0; while [ ! -s ${JOBS}/${id}.pid ] && [ $i -lt 50 ]; do i=$((i+1)); sleep 0.1; done`,
282
+ ].join('\n');
283
+ const started = await this.#run(this.spec.engine, ['exec', '--interactive', this.name, '/bin/sh', '-s'], { input: launch, timeoutMs: 30_000 });
284
+ if (started.code !== 0) {
285
+ throw new SandboxError(`could not start the job: ${message(started)}`);
286
+ }
287
+ const job = { id, command, cwd, startedAt: Date.now() };
288
+ this.#jobs.set(id, job);
289
+ return job;
290
+ }
291
+ /** A window of a job's log, plus whether it is still going. */
292
+ async readJob(id, from = 1) {
293
+ const job = this.#jobs.get(id);
294
+ if (!job) {
295
+ return { error: `no such job: ${id}`, hint: 'ids come from run_command_background' };
296
+ }
297
+ await this.start();
298
+ const first = Math.max(1, Math.trunc(from));
299
+ const script = [
300
+ `cd ${JOBS} || exit 0`,
301
+ `printf 'lines=%s\\n' "$(wc -l < ${id}.log 2>/dev/null || echo 0)"`,
302
+ `if [ -f ${id}.exit ]; then printf 'exit=%s\\n' "$(cat ${id}.exit)"; fi`,
303
+ `printf -- '---\\n'`,
304
+ `tail -n +${first} ${id}.log 2>/dev/null | head -n ${MAX_LOG_LINES}`,
305
+ ].join('\n');
306
+ const res = await this.#run(this.spec.engine, ['exec', '--interactive', this.name, '/bin/sh', '-s'], { input: script, timeoutMs: 30_000 });
307
+ const [head, ...rest] = res.stdout.split('\n---\n');
308
+ const meta = Object.fromEntries(head
309
+ .split('\n')
310
+ .filter(Boolean)
311
+ .map((l) => l.split('=', 2)));
312
+ const output = rest.join('\n---\n');
313
+ const lines = Number(meta.lines ?? 0);
314
+ const shown = output ? output.replace(/\n$/, '').split('\n').length : 0;
315
+ return {
316
+ job_id: id,
317
+ command: job.command,
318
+ running: meta.exit === undefined,
319
+ exit_code: meta.exit === undefined ? undefined : Number(meta.exit),
320
+ duration_ms: Date.now() - job.startedAt,
321
+ start_line: first,
322
+ end_line: first + shown - 1,
323
+ lines,
324
+ truncated: first + shown - 1 < lines || undefined,
325
+ output,
326
+ };
327
+ }
328
+ /** Signals a job's whole process group, so its children go too. */
329
+ async stopJob(id) {
330
+ if (!this.#jobs.has(id)) {
331
+ return { error: `no such job: ${id}`, hint: 'ids come from run_command_background' };
332
+ }
333
+ await this.start();
334
+ const script = [
335
+ `pid=$(cat ${JOBS}/${id}.pid 2>/dev/null)`,
336
+ `[ -n "$pid" ] || { echo "already gone"; exit 0; }`,
337
+ `kill -TERM -"$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true`,
338
+ `sleep 1`,
339
+ `kill -KILL -"$pid" 2>/dev/null || true`,
340
+ `echo stopped`,
341
+ ].join('\n');
342
+ const res = await this.#run(this.spec.engine, ['exec', '--interactive', this.name, '/bin/sh', '-s'], { input: script, timeoutMs: 30_000 });
343
+ this.#jobs.delete(id);
344
+ return { job_id: id, stopped: res.code === 0 };
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 dispose() {
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
+ #podman(args) {
385
+ return this.#run(this.spec.engine, args, { timeoutMs: 120_000 });
386
+ }
387
+ async #must(args, what) {
388
+ const res = await this.#podman(args);
389
+ if (res.code !== 0) {
390
+ throw new SandboxError(`${what}: ${message(res)}`);
391
+ }
392
+ return res;
393
+ }
394
+ }
395
+ function mount(m) {
396
+ return `${m.host}:${m.at}${m.readOnly ? ':ro' : ''}`;
397
+ }
398
+ function message(res) {
399
+ return (res.stderr.trim() || res.stdout.trim() || `exit ${res.code}`).split('\n')[0];
400
+ }
401
+ /** Only ever applied to paths this module derived, never to a model's text. */
402
+ function quote(s) {
403
+ return `'${s.replaceAll("'", `'\\''`)}'`;
404
+ }
405
+ function jobId() {
406
+ return `job-${createHash('sha256').update(`${Date.now()}${Math.random()}`).digest('hex').slice(0, 6)}`;
407
+ }
408
+ function resolveSpec(opts) {
409
+ const workdir = opts.workdir ?? SANDBOX_MOUNT;
410
+ if (!posix.isAbsolute(workdir)) {
411
+ throw new SandboxError(`sandbox workdir must be absolute: ${workdir}`);
412
+ }
413
+ return {
414
+ root: opts.root,
415
+ key: opts.key,
416
+ image: opts.image ?? DEFAULT_IMAGE,
417
+ cpus: opts.cpus ?? 0,
418
+ memory: opts.memory ?? 0,
419
+ network: opts.network ?? 'bridge',
420
+ workdir,
421
+ timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS / 1000,
422
+ user: opts.user,
423
+ persist: opts.persist ?? false,
424
+ env: opts.env ?? {},
425
+ readOnly: opts.readOnly ?? false,
426
+ mounts: opts.mounts ?? [],
427
+ engine: opts.engine ?? 'podman',
428
+ };
429
+ }
430
+ /**
431
+ * The container's name is a function of its configuration. Change the image
432
+ * and a new container appears rather than an old one quietly persisting with
433
+ * the wrong rootfs — which is the failure mode `persist` would otherwise have.
434
+ */
435
+ function containerName(spec) {
436
+ const digest = createHash('sha256')
437
+ .update(JSON.stringify([
438
+ spec.root,
439
+ spec.image,
440
+ spec.cpus,
441
+ spec.memory,
442
+ spec.network,
443
+ spec.workdir,
444
+ spec.user ?? null,
445
+ spec.readOnly,
446
+ spec.mounts,
447
+ Object.entries(spec.env).sort(),
448
+ ]))
449
+ .digest('hex')
450
+ .slice(0, 10);
451
+ const key = spec.key
452
+ .toLowerCase()
453
+ .replace(/[^a-z0-9]+/g, '-')
454
+ .replace(/^-|-$/g, '');
455
+ return `zn-${key || 'session'}-${digest}`;
456
+ }
457
+ // ---------------------------------------------------------------------------
458
+ // One container per configuration
459
+ //
460
+ // Agents share a sandbox by default, and that is usually what an author means:
461
+ // they already share the workspace, and a hand-off is supposed to be
462
+ // continuous — whatever the first agent installed should still be there when
463
+ // the second one takes over.
464
+ //
465
+ // It stops being what they mean the moment two agents need different images.
466
+ // So the tool object stays single — one name, one schema, one prompt-cache
467
+ // prefix — and picks its container from `tc.agent` at call time. Two agents
468
+ // that resolve to the same spec still share one container, because the name is
469
+ // derived from the spec rather than from who asked.
470
+ // ---------------------------------------------------------------------------
471
+ export class SandboxPool {
472
+ #base;
473
+ #agents;
474
+ #boxes = new Map();
475
+ constructor(opts) {
476
+ const { agents, ...base } = opts;
477
+ this.#base = base;
478
+ this.#agents = agents ?? {};
479
+ }
480
+ for(agent) {
481
+ const override = agent ? this.#agents[agent] : undefined;
482
+ const box = new Sandbox(override ? { ...this.#base, ...override } : this.#base);
483
+ const existing = this.#boxes.get(box.name);
484
+ if (existing) {
485
+ return existing;
486
+ }
487
+ this.#boxes.set(box.name, box);
488
+ return box;
489
+ }
490
+ /** The containers that were actually started, for a status line. */
491
+ get running() {
492
+ return [...this.#boxes.values()].filter((b) => b.started);
493
+ }
494
+ async dispose() {
495
+ await Promise.all([...this.#boxes.values()].map((b) => b.dispose()));
496
+ }
497
+ }
498
+ // ---------------------------------------------------------------------------
499
+ // The tools
500
+ // ---------------------------------------------------------------------------
501
+ export function sandboxTools(opts) {
502
+ const pool = opts instanceof SandboxPool ? opts : new SandboxPool(opts);
503
+ const where = {
504
+ type: 'object',
505
+ properties: {
506
+ command: {
507
+ type: 'string',
508
+ description: 'Shell command. Runs under /bin/sh inside the container.',
509
+ },
510
+ cwd: {
511
+ type: 'string',
512
+ description: `Directory to run in, relative to ${SANDBOX_MOUNT}.`,
513
+ },
514
+ },
515
+ required: ['command'],
516
+ additionalProperties: false,
517
+ };
518
+ const runCommand = tool({
519
+ name: 'run_command',
520
+ group: SANDBOX_GROUP,
521
+ description: 'Runs a shell command in a Linux container and waits for it to finish. The ' +
522
+ `workspace is mounted at ${SANDBOX_MOUNT} and is the working directory, so files you ` +
523
+ 'read and write here are the same files the workspace tools see. Everything ' +
524
+ 'else is throwaway. Use this for builds, tests, and package installs; use ' +
525
+ 'run_command_background for anything that does not end on its own, such as a ' +
526
+ 'server or a watcher.',
527
+ parameters: {
528
+ ...where,
529
+ properties: {
530
+ ...where.properties,
531
+ timeout: { type: 'integer', description: 'Seconds to allow before killing it.' },
532
+ },
533
+ },
534
+ execute: (args, tc) => guard(() => pool.for(tc.agent.name).exec(args.command, {
535
+ cwd: args.cwd,
536
+ timeout: args.timeout,
537
+ signal: tc.signal,
538
+ })),
539
+ });
540
+ const runBackground = tool({
541
+ name: 'run_command_background',
542
+ group: SANDBOX_GROUP,
543
+ description: 'Starts a long-running command and returns immediately with a job id. Output ' +
544
+ 'is collected to a log you read with read_command_output; stop it with ' +
545
+ 'stop_command. Use this for servers, watchers, and anything you want to keep ' +
546
+ 'running while you do something else.',
547
+ parameters: where,
548
+ execute: (args, tc) => guard(async () => {
549
+ const job = await pool.for(tc.agent.name).startJob(args.command, { cwd: args.cwd });
550
+ return {
551
+ job_id: job.id,
552
+ started: true,
553
+ hint: 'read_command_output shows what it has printed so far',
554
+ };
555
+ }),
556
+ });
557
+ const readOutput = tool({
558
+ name: 'read_command_output',
559
+ group: SANDBOX_GROUP,
560
+ description: "Reads a background job's output and says whether it is still running. Pass " +
561
+ 'start_line to continue from where the last read ended, which is `end_line + 1`.',
562
+ parameters: {
563
+ type: 'object',
564
+ properties: {
565
+ job_id: { type: 'string', description: 'From run_command_background.' },
566
+ start_line: { type: 'integer', description: 'First log line to return, 1-based.' },
567
+ },
568
+ required: ['job_id'],
569
+ additionalProperties: false,
570
+ },
571
+ execute: (args, tc) => guard(() => pool.for(tc.agent.name).readJob(args.job_id, args.start_line)),
572
+ });
573
+ const stop = tool({
574
+ name: 'stop_command',
575
+ group: SANDBOX_GROUP,
576
+ description: 'Stops a background job and everything it started.',
577
+ parameters: {
578
+ type: 'object',
579
+ properties: { job_id: { type: 'string', description: 'From run_command_background.' } },
580
+ required: ['job_id'],
581
+ additionalProperties: false,
582
+ },
583
+ execute: (args, tc) => guard(() => pool.for(tc.agent.name).stopJob(args.job_id)),
584
+ });
585
+ return [runCommand, runBackground, readOutput, stop];
586
+ }
587
+ /**
588
+ * A missing engine and a path outside the workspace are things the model can
589
+ * be told about; anything else is a bug and should reach the runner as one.
590
+ */
591
+ async function guard(fn) {
592
+ try {
593
+ return await fn();
594
+ }
595
+ catch (err) {
596
+ if (err instanceof SandboxError) {
597
+ return { error: err.message, hint: err.hint };
598
+ }
599
+ throw err;
600
+ }
601
+ }
602
+ //# sourceMappingURL=sandbox.js.map
@@ -0,0 +1,53 @@
1
+ import { type AnyTool } from '../types.ts';
2
+ export interface WorkspaceOptions {
3
+ root: string;
4
+ /** refuse every mutating tool; `zen run --read-only` */
5
+ readOnly?: boolean;
6
+ /**
7
+ * An absolute path the root is *also* known by, because something else has
8
+ * mounted it there — the sandbox bind-mounts the workspace at `/workspace`,
9
+ * so `run_command` returns compiler errors, stack traces and `pwd` output
10
+ * naming files under a prefix the file tools would otherwise refuse. Set
11
+ * it and both spellings reach the same file.
12
+ */
13
+ mount?: string;
14
+ }
15
+ export declare class Workspace {
16
+ #private;
17
+ readonly root: string;
18
+ readonly readOnly: boolean;
19
+ readonly mount: string | undefined;
20
+ constructor(opts: WorkspaceOptions);
21
+ /**
22
+ * The one gate. Returns an absolute path inside the workspace, or throws.
23
+ *
24
+ * A path that does not exist yet cannot be realpath'd, so the nearest
25
+ * existing ancestor is resolved instead and the remainder appended — which
26
+ * is exactly what a create needs and closes the same hole a create opens.
27
+ */
28
+ within(input: string): string;
29
+ /** Where a path sits under the root, in posix form. `.` is the root. */
30
+ rel(path: string): string;
31
+ /**
32
+ * How a path is written back to the model, and the reason `mount` is not
33
+ * only about what comes in. A shell in the sandbox prints the mounted name
34
+ * — `find` returns /workspace/src/a.ts — so if the file tools answered with
35
+ * `src/a.ts` the model would be holding two vocabularies for one tree and
36
+ * would have to translate between them to feed one tool's output to the
37
+ * other. With a mount configured every path in and every path out is the
38
+ * mounted one, and copying a path from a command's output into read_file
39
+ * is exact. Without one, everything stays relative as before.
40
+ */
41
+ show(path: string): string;
42
+ mutable(): void;
43
+ }
44
+ export type FileFormat = 'text' | 'binary' | 'image' | 'audio' | 'video' | 'pdf' | 'archive';
45
+ export interface FileInfo {
46
+ name: string;
47
+ kind: 'file' | 'dir' | 'link' | 'other';
48
+ format?: FileFormat;
49
+ bytes?: number;
50
+ lines?: number;
51
+ }
52
+ export declare function workspaceTools<TCtx = unknown>(opts: WorkspaceOptions): AnyTool<TCtx>[];
53
+ //# sourceMappingURL=workspace.d.ts.map