nexus-ai-pro 2.6.0 → 2.7.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/API_STABILITY.md +52 -0
- package/CHANGELOG.md +119 -1
- package/README.md +8 -5
- package/dist/agent/create-agent.js +16 -3
- package/dist/agent/glob.d.ts +24 -0
- package/dist/agent/glob.js +72 -0
- package/dist/agent/index.d.ts +1 -0
- package/dist/agent/index.js +1 -0
- package/dist/agent/interpreter.d.ts +125 -0
- package/dist/agent/interpreter.js +270 -0
- package/dist/agent/sandbox-container.d.ts +48 -0
- package/dist/agent/sandbox-container.js +291 -0
- package/dist/agent/sandbox.d.ts +50 -1
- package/dist/agent/sandbox.js +155 -2
- package/dist/agent/tool-search.d.ts +39 -0
- package/dist/agent/tool-search.js +113 -0
- package/dist/agent/tool.d.ts +2 -0
- package/dist/agent/tool.js +1 -0
- package/dist/context/window.js +4 -0
- package/dist/deep-agent/helpers.d.ts +57 -0
- package/dist/deep-agent/helpers.js +235 -0
- package/dist/deep-agent/index.d.ts +20 -1
- package/dist/deep-agent/index.js +69 -51
- package/dist/evaluate/generate.d.ts +171 -0
- package/dist/evaluate/generate.js +266 -0
- package/dist/evaluate/index.d.ts +1 -1
- package/dist/evaluate/index.js +1 -1
- package/dist/evaluate/online.d.ts +114 -0
- package/dist/evaluate/online.js +261 -0
- package/dist/evaluate/run.d.ts +28 -0
- package/dist/evaluate/run.js +56 -5
- package/dist/graph/graph.d.ts +7 -1
- package/dist/graph/graph.js +12 -1
- package/dist/operations/runner.js +1 -1
- package/dist/providers/anthropic.d.ts +6 -0
- package/dist/providers/anthropic.js +36 -9
- package/dist/providers/content.d.ts +33 -1
- package/dist/providers/content.js +62 -0
- package/dist/providers/google.js +14 -5
- package/dist/providers/openai.d.ts +8 -4
- package/dist/providers/openai.js +75 -34
- package/dist/runtime/index.d.ts +1 -1
- package/dist/security/index.d.ts +1 -1
- package/dist/security/index.js +1 -1
- package/dist/security/injection-detector.d.ts +18 -0
- package/dist/security/injection-detector.js +39 -4
- package/dist/security/schema-validator.js +11 -2
- package/dist/types/messages.d.ts +57 -3
- package/dist/utils/tokenizer.js +4 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist-cjs/agent/create-agent.js +15 -2
- package/dist-cjs/agent/glob.d.ts +24 -0
- package/dist-cjs/agent/glob.js +77 -0
- package/dist-cjs/agent/index.d.ts +1 -0
- package/dist-cjs/agent/index.js +5 -1
- package/dist-cjs/agent/interpreter.d.ts +125 -0
- package/dist-cjs/agent/interpreter.js +308 -0
- package/dist-cjs/agent/sandbox-container.d.ts +48 -0
- package/dist-cjs/agent/sandbox-container.js +327 -0
- package/dist-cjs/agent/sandbox.d.ts +50 -1
- package/dist-cjs/agent/sandbox.js +155 -2
- package/dist-cjs/agent/tool-search.d.ts +39 -0
- package/dist-cjs/agent/tool-search.js +118 -0
- package/dist-cjs/agent/tool.d.ts +2 -0
- package/dist-cjs/agent/tool.js +1 -0
- package/dist-cjs/context/window.js +4 -0
- package/dist-cjs/deep-agent/helpers.d.ts +57 -0
- package/dist-cjs/deep-agent/helpers.js +238 -0
- package/dist-cjs/deep-agent/index.d.ts +20 -1
- package/dist-cjs/deep-agent/index.js +68 -50
- package/dist-cjs/evaluate/generate.d.ts +171 -0
- package/dist-cjs/evaluate/generate.js +272 -0
- package/dist-cjs/evaluate/index.d.ts +1 -1
- package/dist-cjs/evaluate/index.js +2 -1
- package/dist-cjs/evaluate/online.d.ts +114 -0
- package/dist-cjs/evaluate/online.js +264 -0
- package/dist-cjs/evaluate/run.d.ts +28 -0
- package/dist-cjs/evaluate/run.js +58 -5
- package/dist-cjs/graph/graph.d.ts +7 -1
- package/dist-cjs/graph/graph.js +13 -1
- package/dist-cjs/operations/runner.js +1 -1
- package/dist-cjs/providers/anthropic.d.ts +6 -0
- package/dist-cjs/providers/anthropic.js +35 -8
- package/dist-cjs/providers/content.d.ts +33 -1
- package/dist-cjs/providers/content.js +69 -0
- package/dist-cjs/providers/google.js +13 -4
- package/dist-cjs/providers/openai.d.ts +8 -4
- package/dist-cjs/providers/openai.js +74 -33
- package/dist-cjs/runtime/index.d.ts +1 -1
- package/dist-cjs/security/index.d.ts +1 -1
- package/dist-cjs/security/index.js +2 -1
- package/dist-cjs/security/injection-detector.d.ts +18 -0
- package/dist-cjs/security/injection-detector.js +40 -4
- package/dist-cjs/security/schema-validator.js +11 -2
- package/dist-cjs/types/messages.d.ts +57 -3
- package/dist-cjs/utils/tokenizer.js +4 -0
- package/dist-cjs/version.d.ts +1 -1
- package/dist-cjs/version.js +1 -1
- package/llms.txt +1 -1
- package/package.json +53 -1
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { capabilitiesOf } from './capabilities.js';
|
|
2
|
+
import { isToolOutput, ToolExecutor } from './tool.js';
|
|
3
|
+
export function workerInterpreter(options = {}) {
|
|
4
|
+
const maxLog = options.maxLogChars ?? 64_000;
|
|
5
|
+
const maxCalls = options.maxCalls ?? 100;
|
|
6
|
+
return {
|
|
7
|
+
name: 'worker (not a security boundary)',
|
|
8
|
+
language: 'JavaScript',
|
|
9
|
+
async run(code, runOptions = {}) {
|
|
10
|
+
const { Worker } = await import('node:worker_threads');
|
|
11
|
+
const started = Date.now();
|
|
12
|
+
const tools = runOptions.tools ?? {};
|
|
13
|
+
const logs = [];
|
|
14
|
+
const calls = [];
|
|
15
|
+
let logged = 0;
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
const memory = options.memoryMb ?? 64;
|
|
18
|
+
const worker = new Worker(WORKER_SOURCE, {
|
|
19
|
+
eval: true,
|
|
20
|
+
workerData: { code },
|
|
21
|
+
resourceLimits: {
|
|
22
|
+
maxOldGenerationSizeMb: memory,
|
|
23
|
+
maxYoungGenerationSizeMb: Math.max(4, Math.ceil(memory / 8)),
|
|
24
|
+
},
|
|
25
|
+
stdout: true,
|
|
26
|
+
stderr: true,
|
|
27
|
+
});
|
|
28
|
+
let settled = false;
|
|
29
|
+
const finish = (result) => {
|
|
30
|
+
if (settled)
|
|
31
|
+
return;
|
|
32
|
+
settled = true;
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
runOptions.signal?.removeEventListener('abort', onAbort);
|
|
35
|
+
void worker.terminate();
|
|
36
|
+
resolve({ ...result, logs, calls, durationMs: Date.now() - started });
|
|
37
|
+
};
|
|
38
|
+
const limit = runOptions.timeoutMs ?? options.timeoutMs ?? 10_000;
|
|
39
|
+
const timer = setTimeout(() => finish({ ok: false, error: `The code ran past its ${limit} ms timeout`, timedOut: true }), limit);
|
|
40
|
+
const onAbort = () => finish({ ok: false, error: 'The run was aborted', timedOut: false });
|
|
41
|
+
if (runOptions.signal?.aborted)
|
|
42
|
+
onAbort();
|
|
43
|
+
runOptions.signal?.addEventListener('abort', onAbort, { once: true });
|
|
44
|
+
worker.on('message', (message) => {
|
|
45
|
+
if (message.type === 'log') {
|
|
46
|
+
if (logged >= maxLog)
|
|
47
|
+
return;
|
|
48
|
+
const line = message.line.slice(0, maxLog - logged);
|
|
49
|
+
logged += line.length;
|
|
50
|
+
logs.push(line);
|
|
51
|
+
}
|
|
52
|
+
else if (message.type === 'call') {
|
|
53
|
+
void answer(message);
|
|
54
|
+
}
|
|
55
|
+
else if (message.type === 'done') {
|
|
56
|
+
finish(message.ok
|
|
57
|
+
? { ok: true, value: message.value, timedOut: false }
|
|
58
|
+
: { ok: false, error: message.error, timedOut: false });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
worker.on('error', (error) => {
|
|
62
|
+
const outOfMemory = error.code === 'ERR_WORKER_OUT_OF_MEMORY';
|
|
63
|
+
finish({
|
|
64
|
+
ok: false,
|
|
65
|
+
error: outOfMemory ? `The code ran out of memory, past ${memory} MB` : error.message,
|
|
66
|
+
timedOut: false,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
worker.on('exit', () => finish({ ok: false, error: 'The interpreter stopped', timedOut: false }));
|
|
70
|
+
const answer = async (message) => {
|
|
71
|
+
const reply = (body) => {
|
|
72
|
+
if (settled)
|
|
73
|
+
return;
|
|
74
|
+
try {
|
|
75
|
+
worker.postMessage({ type: 'result', id: message.id, ...body });
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
worker.postMessage({ type: 'result', id: message.id, ...body, value: toJson(body) });
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const fn = Object.hasOwn(tools, message.name) ? tools[message.name] : undefined;
|
|
82
|
+
if (calls.length >= maxCalls) {
|
|
83
|
+
calls.push({ name: message.name, args: message.args, ok: false });
|
|
84
|
+
reply({ ok: false, error: `The code made more than ${maxCalls} calls` });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const call = { name: message.name, args: message.args, ok: false };
|
|
88
|
+
calls.push(call);
|
|
89
|
+
if (!fn) {
|
|
90
|
+
reply({ ok: false, error: `There is no tool named ${message.name}; the tools are ${namesOf(tools)}` });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const value = await fn((message.args ?? {}));
|
|
95
|
+
call.ok = true;
|
|
96
|
+
reply({ ok: true, value });
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
reply({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const namesOf = (tools) => Object.keys(tools).join(', ') || 'none';
|
|
107
|
+
function toJson(body) {
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(JSON.stringify(body.value ?? null));
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return String(body.value);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const WORKER_SOURCE = `
|
|
116
|
+
const { parentPort, workerData } = require('node:worker_threads');
|
|
117
|
+
const pending = new Map();
|
|
118
|
+
let next = 0;
|
|
119
|
+
const show = (value) => {
|
|
120
|
+
if (typeof value === 'string') return value;
|
|
121
|
+
try { return JSON.stringify(value); } catch { return String(value); }
|
|
122
|
+
};
|
|
123
|
+
const say = (...values) => parentPort.postMessage({ type: 'log', line: values.map(show).join(' ') });
|
|
124
|
+
const console = { log: say, info: say, warn: say, error: say, debug: say };
|
|
125
|
+
const tools = new Proxy({}, {
|
|
126
|
+
get(_target, name) {
|
|
127
|
+
if (typeof name !== 'string' || name === 'then') return undefined;
|
|
128
|
+
return (args = {}) => new Promise((resolve, reject) => {
|
|
129
|
+
const id = next++;
|
|
130
|
+
pending.set(id, { resolve, reject });
|
|
131
|
+
parentPort.postMessage({ type: 'call', id, name, args });
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
parentPort.on('message', (message) => {
|
|
136
|
+
if (message.type !== 'result') return;
|
|
137
|
+
const waiting = pending.get(message.id);
|
|
138
|
+
pending.delete(message.id);
|
|
139
|
+
if (!waiting) return;
|
|
140
|
+
if (message.ok) waiting.resolve(message.value);
|
|
141
|
+
else waiting.reject(new Error(message.error));
|
|
142
|
+
});
|
|
143
|
+
const AsyncFunction = (async () => {}).constructor;
|
|
144
|
+
(async () => {
|
|
145
|
+
try {
|
|
146
|
+
const value = await new AsyncFunction('tools', 'console', workerData.code)(tools, console);
|
|
147
|
+
try {
|
|
148
|
+
parentPort.postMessage({ type: 'done', ok: true, value });
|
|
149
|
+
} catch {
|
|
150
|
+
parentPort.postMessage({ type: 'done', ok: true, value: show(value) });
|
|
151
|
+
}
|
|
152
|
+
} catch (error) {
|
|
153
|
+
parentPort.postMessage({ type: 'done', ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
154
|
+
}
|
|
155
|
+
})();
|
|
156
|
+
`;
|
|
157
|
+
export function codeTool(interpreter, options) {
|
|
158
|
+
const executor = new ToolExecutor([...options.tools]);
|
|
159
|
+
const listed = options.tools
|
|
160
|
+
.map((tool) => `- ${tool.name}(${JSON.stringify(tool.parameters)}): ${tool.description}`)
|
|
161
|
+
.join('\n');
|
|
162
|
+
return {
|
|
163
|
+
name: options.name ?? 'run_code',
|
|
164
|
+
description: `Runs ${interpreter.language} code in one step: the body of an async function. Call the tools below with \`await tools.<name>(args)\`, \`return\` the value you need, and use console.log for notes. Use it to call tools many times, at once, or to filter what they return before you read it.\nTools:\n${listed || '- none'}`,
|
|
165
|
+
parameters: {
|
|
166
|
+
type: 'object',
|
|
167
|
+
properties: { code: { type: 'string', description: `The ${interpreter.language} to run` } },
|
|
168
|
+
required: ['code'],
|
|
169
|
+
},
|
|
170
|
+
capabilities: ['code'],
|
|
171
|
+
execute: async (args, context) => {
|
|
172
|
+
const tools = {};
|
|
173
|
+
for (const definition of options.tools) {
|
|
174
|
+
tools[definition.name] = async (callArgs) => {
|
|
175
|
+
if (options.permissions) {
|
|
176
|
+
const verdict = await options.permissions.decide({
|
|
177
|
+
tool: definition.name,
|
|
178
|
+
args: callArgs,
|
|
179
|
+
capabilities: capabilitiesOf(definition, callArgs),
|
|
180
|
+
...(context?.principal ? { principal: context.principal } : {}),
|
|
181
|
+
});
|
|
182
|
+
if (verdict.decision === 'deny')
|
|
183
|
+
throw new Error(`Permission denied: ${verdict.reason}`);
|
|
184
|
+
if (verdict.decision === 'ask') {
|
|
185
|
+
throw new Error(`${definition.name} needs a person's approval, so call it directly: ${verdict.reason}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const result = await executor.execute(definition.name, callArgs, context);
|
|
189
|
+
if (!result.ok)
|
|
190
|
+
throw new Error(result.error);
|
|
191
|
+
return isToolOutput(result.result)
|
|
192
|
+
? result.result.content.map((part) => (part.type === 'text' ? part.text : `[${part.type}]`)).join('\n')
|
|
193
|
+
: result.result;
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const outcome = await interpreter.run(String(args.code ?? ''), {
|
|
197
|
+
tools,
|
|
198
|
+
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
|
199
|
+
...(context?.signal ? { signal: context.signal } : {}),
|
|
200
|
+
});
|
|
201
|
+
return {
|
|
202
|
+
ok: outcome.ok,
|
|
203
|
+
...(outcome.ok ? { value: outcome.value ?? null } : { error: outcome.error }),
|
|
204
|
+
...(outcome.logs.length ? { logs: outcome.logs } : {}),
|
|
205
|
+
calls: outcome.calls.map((call) => `${call.name}${call.ok ? '' : ' (failed)'}`),
|
|
206
|
+
};
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
export async function runInterpreterConformance(interpreter) {
|
|
211
|
+
const checks = [];
|
|
212
|
+
const check = async (name, run) => {
|
|
213
|
+
try {
|
|
214
|
+
const problem = await run();
|
|
215
|
+
checks.push(problem ? { name, ok: false, detail: problem } : { name, ok: true });
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
checks.push({ name, ok: false, detail: error instanceof Error ? error.message : String(error) });
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
const same = (actual, expected, what) => JSON.stringify(actual) === JSON.stringify(expected)
|
|
222
|
+
? undefined
|
|
223
|
+
: `${what}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`;
|
|
224
|
+
const double = async (args) => Number(args.n) * 2;
|
|
225
|
+
await check('code returns a value, logs, and calls tools in order and at once', async () => {
|
|
226
|
+
const result = await interpreter.run(`const one = await tools.double({ n: 1 });
|
|
227
|
+
const many = await Promise.all([2, 3].map((n) => tools.double({ n })));
|
|
228
|
+
console.log('doubled', one, many);
|
|
229
|
+
return { one, many };`, { tools: { double } });
|
|
230
|
+
return (same(result.ok, true, `ok (${result.error})`) ??
|
|
231
|
+
same(result.value, { one: 2, many: [4, 6] }, 'value') ??
|
|
232
|
+
same(result.logs, ['doubled 2 [4,6]'], 'logs') ??
|
|
233
|
+
same(result.calls.map((call) => call.name), ['double', 'double', 'double'], 'calls'));
|
|
234
|
+
});
|
|
235
|
+
await check('a failing tool rejects in the code, and an unknown tool fails', async () => {
|
|
236
|
+
const fail = async () => {
|
|
237
|
+
throw new Error('no such order');
|
|
238
|
+
};
|
|
239
|
+
const result = await interpreter.run(`let caught = '';
|
|
240
|
+
try { await tools.lookup({ id: 1 }); } catch (error) { caught = error.message; }
|
|
241
|
+
let unknown = '';
|
|
242
|
+
try { await tools.missing({}); } catch (error) { unknown = error.message; }
|
|
243
|
+
return { caught, unknown: unknown.length > 0 };`, { tools: { lookup: fail } });
|
|
244
|
+
return same(result.value, { caught: 'no such order', unknown: true }, 'value');
|
|
245
|
+
});
|
|
246
|
+
await check('code that throws is reported', async () => {
|
|
247
|
+
const result = await interpreter.run(`throw new Error('broken on purpose');`);
|
|
248
|
+
return (same(result.ok, false, 'ok') ??
|
|
249
|
+
(result.error?.includes('broken on purpose') ? undefined : `error: ${result.error}`));
|
|
250
|
+
});
|
|
251
|
+
await check('an endless loop is stopped at the timeout', async () => {
|
|
252
|
+
const result = await interpreter.run('while (true) {}', { timeoutMs: 300 });
|
|
253
|
+
if (!result.timedOut || result.ok)
|
|
254
|
+
return `the loop was not reported as timed out: ${JSON.stringify(result)}`;
|
|
255
|
+
const after = await interpreter.run('return 1 + 1;');
|
|
256
|
+
return same(after.value, 2, 'a run after the timeout');
|
|
257
|
+
});
|
|
258
|
+
await check('a runaway allocation is stopped', async () => {
|
|
259
|
+
const result = await interpreter.run('const kept = []; for (;;) kept.push(new Array(1e6).fill(Math.random()));', {
|
|
260
|
+
timeoutMs: 30_000,
|
|
261
|
+
});
|
|
262
|
+
if (result.ok)
|
|
263
|
+
return 'the allocation finished';
|
|
264
|
+
if (result.timedOut)
|
|
265
|
+
return 'the allocation ran to the timeout instead of meeting a memory limit';
|
|
266
|
+
const after = await interpreter.run('return 1 + 1;');
|
|
267
|
+
return same(after.value, 2, 'a run after the allocation');
|
|
268
|
+
});
|
|
269
|
+
return { interpreter: interpreter.name, passed: checks.every((item) => item.ok), checks };
|
|
270
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type Sandbox } from './sandbox.js';
|
|
2
|
+
/** Options for `containerSandbox()`. */
|
|
3
|
+
export interface ContainerSandboxOptions {
|
|
4
|
+
/**
|
|
5
|
+
* The image, such as `node:22-alpine`. It needs a POSIX shell, `cat`, `find`, `tar`, and the
|
|
6
|
+
* commands the agent is to run.
|
|
7
|
+
*/
|
|
8
|
+
image: string;
|
|
9
|
+
/** The binary that runs containers: `docker`, `podman`, or a path to either. Defaults to `docker`. */
|
|
10
|
+
binary?: string;
|
|
11
|
+
/** Where the files live inside the container, and where commands run. Defaults to `/workspace`. */
|
|
12
|
+
workdir?: string;
|
|
13
|
+
/**
|
|
14
|
+
* The network. `false`, the default, gives the container none at all; `true` gives it the
|
|
15
|
+
* runtime's default network; a string names a network to join.
|
|
16
|
+
*/
|
|
17
|
+
network?: boolean | string;
|
|
18
|
+
/** The memory limit, as the runtime takes it. Swap is held to the same limit. Defaults to `512m`. */
|
|
19
|
+
memory?: string;
|
|
20
|
+
/** How many CPUs it may use. Defaults to 1. */
|
|
21
|
+
cpus?: number;
|
|
22
|
+
/** The most processes it may run at once. Defaults to 256. */
|
|
23
|
+
pids?: number;
|
|
24
|
+
/** Environment variables every command gets, on top of the image's own. */
|
|
25
|
+
env?: Record<string, string>;
|
|
26
|
+
/** The timeout for a command that sets none. Defaults to 30 seconds. */
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
/** Output kept per stream, in bytes. Defaults to 1 MB. */
|
|
29
|
+
maxOutputBytes?: number;
|
|
30
|
+
/**
|
|
31
|
+
* More arguments for `run`, before the image, for what these options do not cover, such as
|
|
32
|
+
* `--user` with a volume that user can write.
|
|
33
|
+
*/
|
|
34
|
+
runArgs?: readonly string[];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A sandbox in a container, for production. Every command runs through `exec` in one container per
|
|
38
|
+
* sandbox, and a command past its timeout is killed inside the container with everything it
|
|
39
|
+
* started. `isolation` reports the network as isolated only when the container has none.
|
|
40
|
+
*
|
|
41
|
+
* ```ts
|
|
42
|
+
* const sandbox = containerSandbox({ image: 'node:22-alpine' });
|
|
43
|
+
* const agent = createDeepAgent({ client: ai, sandbox, commands: ['node', 'npm test'] });
|
|
44
|
+
* // …
|
|
45
|
+
* await sandbox.dispose?.();
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export declare function containerSandbox(options: ContainerSandboxOptions): Sandbox;
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { GREP_FILE_BYTES, GREP_TOTAL_BYTES, globMatcher, matchingLines, WALK_LIMIT } from './glob.js';
|
|
2
|
+
import { SandboxPathError, } from './sandbox.js';
|
|
3
|
+
export function containerSandbox(options) {
|
|
4
|
+
const binary = options.binary ?? 'docker';
|
|
5
|
+
const workdir = `/${(options.workdir ?? '/workspace').replace(/^\/+|\/+$/g, '')}`;
|
|
6
|
+
const maxOutput = options.maxOutputBytes ?? 1_048_576;
|
|
7
|
+
const network = options.network ?? false;
|
|
8
|
+
const label = `nexus-sandbox-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
9
|
+
let marks = 0;
|
|
10
|
+
const inside = (file) => {
|
|
11
|
+
const unified = file.replace(/\\/g, '/');
|
|
12
|
+
if (unified.includes('\0'))
|
|
13
|
+
throw new SandboxPathError(file);
|
|
14
|
+
const parts = [];
|
|
15
|
+
for (const segment of (unified.startsWith('/') ? unified : `${workdir}/${unified}`).split('/')) {
|
|
16
|
+
if (segment === '' || segment === '.')
|
|
17
|
+
continue;
|
|
18
|
+
if (segment !== '..')
|
|
19
|
+
parts.push(segment);
|
|
20
|
+
else if (parts.pop() === undefined)
|
|
21
|
+
throw new SandboxPathError(file);
|
|
22
|
+
}
|
|
23
|
+
const resolved = `/${parts.join('/')}`;
|
|
24
|
+
if (resolved !== workdir && !resolved.startsWith(`${workdir}/`))
|
|
25
|
+
throw new SandboxPathError(file);
|
|
26
|
+
return resolved;
|
|
27
|
+
};
|
|
28
|
+
const fromRoot = (path) => (path === workdir ? '.' : path.slice(workdir.length + 1));
|
|
29
|
+
const invoke = async (args, call = {}) => {
|
|
30
|
+
const { spawn } = await import('node:child_process');
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
const child = spawn(binary, args, { windowsHide: true });
|
|
33
|
+
const out = [];
|
|
34
|
+
let kept = 0;
|
|
35
|
+
let stderr = '';
|
|
36
|
+
let timedOut = false;
|
|
37
|
+
let settled = false;
|
|
38
|
+
child.stdout.on('data', (chunk) => {
|
|
39
|
+
const room = (call.keep ?? Number.POSITIVE_INFINITY) - kept;
|
|
40
|
+
if (room <= 0)
|
|
41
|
+
return;
|
|
42
|
+
const part = chunk.byteLength > room ? chunk.subarray(0, room) : chunk;
|
|
43
|
+
out.push(part);
|
|
44
|
+
kept += part.byteLength;
|
|
45
|
+
});
|
|
46
|
+
child.stderr.on('data', (chunk) => {
|
|
47
|
+
if (stderr.length < maxOutput)
|
|
48
|
+
stderr = (stderr + new TextDecoder().decode(chunk)).slice(0, maxOutput);
|
|
49
|
+
});
|
|
50
|
+
const stop = () => {
|
|
51
|
+
call.onStop?.();
|
|
52
|
+
child.kill('SIGKILL');
|
|
53
|
+
};
|
|
54
|
+
const timer = call.timeoutMs === undefined
|
|
55
|
+
? undefined
|
|
56
|
+
: setTimeout(() => {
|
|
57
|
+
timedOut = true;
|
|
58
|
+
stop();
|
|
59
|
+
}, call.timeoutMs);
|
|
60
|
+
call.signal?.addEventListener('abort', stop, { once: true });
|
|
61
|
+
const finish = (code) => {
|
|
62
|
+
if (settled)
|
|
63
|
+
return;
|
|
64
|
+
settled = true;
|
|
65
|
+
if (timer)
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
call.signal?.removeEventListener('abort', stop);
|
|
68
|
+
resolve({ code: timedOut ? null : code, stdout: concat(out), stderr, timedOut });
|
|
69
|
+
};
|
|
70
|
+
child.on('error', (error) => {
|
|
71
|
+
stderr += error.message;
|
|
72
|
+
finish(null);
|
|
73
|
+
});
|
|
74
|
+
child.on('close', (code) => finish(code));
|
|
75
|
+
child.stdin.on('error', () => undefined);
|
|
76
|
+
child.stdin.end(call.stdin ?? '');
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
const failed = (what, result) => new Error(`${what} failed in the container: ${result.stderr.trim() || `exit code ${result.code}`}`);
|
|
80
|
+
let started;
|
|
81
|
+
const container = () => {
|
|
82
|
+
if (started)
|
|
83
|
+
return started;
|
|
84
|
+
const starting = (async () => {
|
|
85
|
+
const result = await invoke([
|
|
86
|
+
'run',
|
|
87
|
+
'--detach',
|
|
88
|
+
'--rm',
|
|
89
|
+
'--init',
|
|
90
|
+
'--name',
|
|
91
|
+
label,
|
|
92
|
+
'--label',
|
|
93
|
+
'nexus-ai-pro.sandbox=1',
|
|
94
|
+
'--workdir',
|
|
95
|
+
workdir,
|
|
96
|
+
...(network === true ? [] : ['--network', network === false ? 'none' : network]),
|
|
97
|
+
'--memory',
|
|
98
|
+
options.memory ?? '512m',
|
|
99
|
+
'--memory-swap',
|
|
100
|
+
options.memory ?? '512m',
|
|
101
|
+
'--cpus',
|
|
102
|
+
String(options.cpus ?? 1),
|
|
103
|
+
'--pids-limit',
|
|
104
|
+
String(options.pids ?? 256),
|
|
105
|
+
'--cap-drop',
|
|
106
|
+
'ALL',
|
|
107
|
+
'--security-opt',
|
|
108
|
+
'no-new-privileges',
|
|
109
|
+
...envArgs(options.env),
|
|
110
|
+
...(options.runArgs ?? []),
|
|
111
|
+
'--entrypoint',
|
|
112
|
+
'tail',
|
|
113
|
+
options.image,
|
|
114
|
+
'-f',
|
|
115
|
+
'/dev/null',
|
|
116
|
+
]);
|
|
117
|
+
if (result.code !== 0)
|
|
118
|
+
throw failed(`Starting ${options.image}`, result);
|
|
119
|
+
return new TextDecoder().decode(result.stdout).trim();
|
|
120
|
+
})();
|
|
121
|
+
started = starting;
|
|
122
|
+
starting.catch(() => {
|
|
123
|
+
if (started === starting)
|
|
124
|
+
started = undefined;
|
|
125
|
+
});
|
|
126
|
+
return starting;
|
|
127
|
+
};
|
|
128
|
+
const shell = async (script, args, stdin, keep) => {
|
|
129
|
+
const id = await container();
|
|
130
|
+
return invoke(['exec', ...(stdin === undefined ? [] : ['--interactive']), id, 'sh', '-c', script, 'sh', ...args], {
|
|
131
|
+
...(stdin === undefined ? {} : { stdin }),
|
|
132
|
+
...(keep === undefined ? {} : { keep }),
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
const walk = async (directory, filter = '') => {
|
|
136
|
+
const start = inside(directory);
|
|
137
|
+
const result = await shell(`find "$1" \\( -name .git -o -name node_modules \\) -prune -o -type f ${filter} -print`, [start]);
|
|
138
|
+
if (result.code !== 0 && result.stdout.byteLength === 0)
|
|
139
|
+
throw failed('Listing files', result);
|
|
140
|
+
return new TextDecoder()
|
|
141
|
+
.decode(result.stdout)
|
|
142
|
+
.split('\n')
|
|
143
|
+
.filter(Boolean)
|
|
144
|
+
.slice(0, WALK_LIMIT)
|
|
145
|
+
.map((path) => ({ fromRoot: fromRoot(path), fromStart: path.slice(start.length).replace(/^\//, '') }))
|
|
146
|
+
.sort((a, b) => (a.fromRoot < b.fromRoot ? -1 : a.fromRoot > b.fromRoot ? 1 : 0));
|
|
147
|
+
};
|
|
148
|
+
const readBytes = async (file) => {
|
|
149
|
+
const result = await shell('cat "$1"', [inside(file)]);
|
|
150
|
+
if (result.code !== 0)
|
|
151
|
+
throw failed(`Reading ${file}`, result);
|
|
152
|
+
return result.stdout;
|
|
153
|
+
};
|
|
154
|
+
return {
|
|
155
|
+
name: `container (${binary} ${options.image})`,
|
|
156
|
+
isolation: { filesystem: true, network: network === false, processes: true },
|
|
157
|
+
async exec(command, execOptions = {}) {
|
|
158
|
+
const id = await container();
|
|
159
|
+
const startedAt = Date.now();
|
|
160
|
+
const mark = `${label}-${++marks}`;
|
|
161
|
+
const result = await invoke([
|
|
162
|
+
'exec',
|
|
163
|
+
'--interactive',
|
|
164
|
+
'--workdir',
|
|
165
|
+
inside(execOptions.cwd ?? '.'),
|
|
166
|
+
...envArgs({ ...options.env, ...execOptions.env, NEXUS_SANDBOX_EXEC: mark }),
|
|
167
|
+
id,
|
|
168
|
+
'sh',
|
|
169
|
+
'-c',
|
|
170
|
+
command,
|
|
171
|
+
], {
|
|
172
|
+
stdin: execOptions.stdin ?? '',
|
|
173
|
+
keep: maxOutput,
|
|
174
|
+
timeoutMs: execOptions.timeoutMs ?? options.timeoutMs ?? 30_000,
|
|
175
|
+
...(execOptions.signal ? { signal: execOptions.signal } : {}),
|
|
176
|
+
onStop: () => {
|
|
177
|
+
void invoke(['exec', id, 'sh', '-c', KILL_MARKED, 'sh', mark]);
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
return {
|
|
181
|
+
exitCode: result.code,
|
|
182
|
+
stdout: new TextDecoder().decode(result.stdout),
|
|
183
|
+
stderr: result.stderr,
|
|
184
|
+
timedOut: result.timedOut,
|
|
185
|
+
durationMs: Date.now() - startedAt,
|
|
186
|
+
};
|
|
187
|
+
},
|
|
188
|
+
async readFile(file) {
|
|
189
|
+
return new TextDecoder().decode(await readBytes(file));
|
|
190
|
+
},
|
|
191
|
+
readBytes,
|
|
192
|
+
async writeFile(file, content) {
|
|
193
|
+
const result = await shell('mkdir -p "$(dirname "$1")" && cat > "$1"', [inside(file)], content);
|
|
194
|
+
if (result.code !== 0)
|
|
195
|
+
throw failed(`Writing ${file}`, result);
|
|
196
|
+
},
|
|
197
|
+
async listFiles(directory = '.') {
|
|
198
|
+
const result = await shell('ls -1A "$1"', [inside(directory)]);
|
|
199
|
+
if (result.code !== 0)
|
|
200
|
+
throw failed(`Listing ${directory}`, result);
|
|
201
|
+
return new TextDecoder().decode(result.stdout).split('\n').filter(Boolean).sort();
|
|
202
|
+
},
|
|
203
|
+
async removeFile(file) {
|
|
204
|
+
const target = inside(file);
|
|
205
|
+
if (target === workdir)
|
|
206
|
+
throw new SandboxPathError(file);
|
|
207
|
+
const result = await shell('rm -rf "$1"', [target]);
|
|
208
|
+
if (result.code !== 0)
|
|
209
|
+
throw failed(`Removing ${file}`, result);
|
|
210
|
+
},
|
|
211
|
+
async glob(pattern, globOptions = {}) {
|
|
212
|
+
const matches = globMatcher(pattern);
|
|
213
|
+
return (await walk(globOptions.cwd ?? '.'))
|
|
214
|
+
.filter((file) => matches(file.fromStart))
|
|
215
|
+
.slice(0, globOptions.limit ?? 1_000)
|
|
216
|
+
.map((file) => file.fromRoot);
|
|
217
|
+
},
|
|
218
|
+
async grep(pattern, grepOptions = {}) {
|
|
219
|
+
const expression = new RegExp(pattern, grepOptions.ignoreCase ? 'i' : '');
|
|
220
|
+
const only = grepOptions.glob ? globMatcher(grepOptions.glob) : undefined;
|
|
221
|
+
const limit = grepOptions.limit ?? 200;
|
|
222
|
+
const candidates = (await walk(grepOptions.cwd ?? '.', `-size -${GREP_FILE_BYTES / 1024 + 1}k`)).filter((file) => !only || only(file.fromStart));
|
|
223
|
+
const found = [];
|
|
224
|
+
let read = 0;
|
|
225
|
+
for (let index = 0; index < candidates.length && found.length < limit && read < GREP_TOTAL_BYTES; index += 200) {
|
|
226
|
+
const batch = candidates.slice(index, index + 200).map((file) => `./${file.fromRoot}`);
|
|
227
|
+
const result = await shell('cd "$1" && shift && tar -cf - "$@"', [workdir, ...batch], undefined, GREP_TOTAL_BYTES - read);
|
|
228
|
+
for (const entry of untar(result.stdout)) {
|
|
229
|
+
read += entry.data.byteLength;
|
|
230
|
+
const file = entry.name.replace(/^\.\//, '');
|
|
231
|
+
found.push(...matchingLines(entry.data, expression, limit - found.length).map((match) => ({ file, ...match })));
|
|
232
|
+
if (found.length >= limit)
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return found;
|
|
237
|
+
},
|
|
238
|
+
async dispose() {
|
|
239
|
+
const current = started;
|
|
240
|
+
started = undefined;
|
|
241
|
+
const id = await current?.catch(() => undefined);
|
|
242
|
+
if (id)
|
|
243
|
+
await invoke(['rm', '--force', id]);
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
const KILL_MARKED = 'for p in /proc/[0-9]*; do if tr "\\000" "\\n" < "$p/environ" 2>/dev/null | grep -qx "NEXUS_SANDBOX_EXEC=$1"; then kill -9 "$(basename "$p")" 2>/dev/null; fi; done; true';
|
|
248
|
+
function envArgs(env) {
|
|
249
|
+
return Object.entries(env ?? {}).flatMap(([key, value]) => ['--env', `${key}=${value}`]);
|
|
250
|
+
}
|
|
251
|
+
function concat(chunks) {
|
|
252
|
+
const bytes = new Uint8Array(chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0));
|
|
253
|
+
let offset = 0;
|
|
254
|
+
for (const chunk of chunks) {
|
|
255
|
+
bytes.set(chunk, offset);
|
|
256
|
+
offset += chunk.byteLength;
|
|
257
|
+
}
|
|
258
|
+
return bytes;
|
|
259
|
+
}
|
|
260
|
+
function untar(tar) {
|
|
261
|
+
const decoder = new TextDecoder();
|
|
262
|
+
const field = (from, length) => {
|
|
263
|
+
const bytes = tar.subarray(from, from + length);
|
|
264
|
+
const end = bytes.indexOf(0);
|
|
265
|
+
return decoder.decode(end < 0 ? bytes : bytes.subarray(0, end));
|
|
266
|
+
};
|
|
267
|
+
const files = [];
|
|
268
|
+
let offset = 0;
|
|
269
|
+
let longName;
|
|
270
|
+
while (offset + 512 <= tar.byteLength) {
|
|
271
|
+
if (tar.subarray(offset, offset + 512).every((byte) => byte === 0))
|
|
272
|
+
break;
|
|
273
|
+
const size = Number.parseInt(field(offset + 124, 12).trim() || '0', 8);
|
|
274
|
+
const type = String.fromCharCode(tar[offset + 156] ?? 0);
|
|
275
|
+
const prefix = field(offset + 345, 155);
|
|
276
|
+
const name = longName ?? (prefix ? `${prefix}/${field(offset, 100)}` : field(offset, 100));
|
|
277
|
+
const data = tar.subarray(offset + 512, offset + 512 + size);
|
|
278
|
+
longName = undefined;
|
|
279
|
+
if (type === 'L') {
|
|
280
|
+
longName = decoder.decode(data).replace(/\0+$/, '');
|
|
281
|
+
}
|
|
282
|
+
else if (type === 'x') {
|
|
283
|
+
longName = /(?:^|\n)\d+ path=([^\n]*)\n/.exec(decoder.decode(data))?.[1];
|
|
284
|
+
}
|
|
285
|
+
else if (type === '0' || type === '\0') {
|
|
286
|
+
files.push({ name, data });
|
|
287
|
+
}
|
|
288
|
+
offset += 512 + Math.ceil(size / 512) * 512;
|
|
289
|
+
}
|
|
290
|
+
return files;
|
|
291
|
+
}
|