@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.
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/dist/agent.d.ts +66 -0
- package/dist/agent.js +97 -0
- package/dist/architecture.d.ts +74 -0
- package/dist/architecture.js +105 -0
- package/dist/embedding.d.ts +60 -0
- package/dist/embedding.js +21 -0
- package/dist/embeddings/gemini.d.ts +24 -0
- package/dist/embeddings/gemini.js +71 -0
- package/dist/embeddings/index.d.ts +4 -0
- package/dist/embeddings/index.js +4 -0
- package/dist/embeddings/openai.d.ts +18 -0
- package/dist/embeddings/openai.js +27 -0
- package/dist/embeddings/openrouter.d.ts +21 -0
- package/dist/embeddings/openrouter.js +54 -0
- package/dist/events.d.ts +144 -0
- package/dist/events.js +110 -0
- package/dist/ids.d.ts +14 -0
- package/dist/ids.js +33 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +25 -0
- package/dist/inspect/client.d.ts +2 -0
- package/dist/inspect/client.js +1326 -0
- package/dist/inspect/css.d.ts +2 -0
- package/dist/inspect/css.js +164 -0
- package/dist/inspect/index.d.ts +9 -0
- package/dist/inspect/index.js +12 -0
- package/dist/inspect/page.d.ts +3 -0
- package/dist/inspect/page.js +93 -0
- package/dist/inspect/report.d.ts +54 -0
- package/dist/inspect/report.js +62 -0
- package/dist/kernel.d.ts +178 -0
- package/dist/kernel.js +929 -0
- package/dist/memory-stores/factory.d.ts +15 -0
- package/dist/memory-stores/factory.js +29 -0
- package/dist/memory-stores/file.d.ts +37 -0
- package/dist/memory-stores/file.js +174 -0
- package/dist/memory-stores/in-memory.d.ts +17 -0
- package/dist/memory-stores/in-memory.js +80 -0
- package/dist/memory-stores/index.d.ts +4 -0
- package/dist/memory-stores/index.js +5 -0
- package/dist/memory.d.ts +115 -0
- package/dist/memory.js +213 -0
- package/dist/model.d.ts +25 -0
- package/dist/model.js +2 -0
- package/dist/models/anthropic.d.ts +36 -0
- package/dist/models/anthropic.js +293 -0
- package/dist/models/factory.d.ts +202 -0
- package/dist/models/factory.js +533 -0
- package/dist/models/gemini.d.ts +45 -0
- package/dist/models/gemini.js +368 -0
- package/dist/models/index.d.ts +7 -0
- package/dist/models/index.js +7 -0
- package/dist/models/openai-chat.d.ts +19 -0
- package/dist/models/openai-chat.js +203 -0
- package/dist/models/openai-responses.d.ts +31 -0
- package/dist/models/openai-responses.js +258 -0
- package/dist/models/openrouter.d.ts +44 -0
- package/dist/models/openrouter.js +230 -0
- package/dist/payload-stores/factory.d.ts +15 -0
- package/dist/payload-stores/factory.js +30 -0
- package/dist/payload-stores/file.d.ts +29 -0
- package/dist/payload-stores/file.js +112 -0
- package/dist/payload-stores/in-memory.d.ts +17 -0
- package/dist/payload-stores/in-memory.js +40 -0
- package/dist/payload-stores/index.d.ts +4 -0
- package/dist/payload-stores/index.js +5 -0
- package/dist/payload.d.ts +89 -0
- package/dist/payload.js +181 -0
- package/dist/project/config.d.ts +302 -0
- package/dist/project/config.js +290 -0
- package/dist/project/index.d.ts +4 -0
- package/dist/project/index.js +4 -0
- package/dist/project/load.d.ts +142 -0
- package/dist/project/load.js +430 -0
- package/dist/project/refs.d.ts +23 -0
- package/dist/project/refs.js +70 -0
- package/dist/prompt.d.ts +66 -0
- package/dist/prompt.js +62 -0
- package/dist/runner.d.ts +103 -0
- package/dist/runner.js +537 -0
- package/dist/services.d.ts +23 -0
- package/dist/services.js +44 -0
- package/dist/skill-providers/factory.d.ts +16 -0
- package/dist/skill-providers/factory.js +26 -0
- package/dist/skill-providers/file.d.ts +44 -0
- package/dist/skill-providers/file.js +209 -0
- package/dist/skill-providers/index.d.ts +4 -0
- package/dist/skill-providers/index.js +5 -0
- package/dist/skill-providers/static.d.ts +13 -0
- package/dist/skill-providers/static.js +47 -0
- package/dist/skills.d.ts +102 -0
- package/dist/skills.js +232 -0
- package/dist/state.d.ts +86 -0
- package/dist/state.js +49 -0
- package/dist/tools/exa.d.ts +23 -0
- package/dist/tools/exa.js +389 -0
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.js +6 -0
- package/dist/tools/sandbox.d.ts +172 -0
- package/dist/tools/sandbox.js +602 -0
- package/dist/tools/workspace.d.ts +53 -0
- package/dist/tools/workspace.js +1034 -0
- package/dist/trajectory.d.ts +213 -0
- package/dist/trajectory.js +232 -0
- package/dist/types.d.ts +195 -0
- package/dist/types.js +134 -0
- 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
|