@agnt5/sdk 0.4.1 → 0.5.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/dist/agent.d.ts +29 -0
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +44 -2
- package/dist/agent.js.map +1 -1
- package/dist/agents-md.d.ts +39 -0
- package/dist/agents-md.d.ts.map +1 -0
- package/dist/agents-md.js +77 -0
- package/dist/agents-md.js.map +1 -0
- package/dist/events.d.ts +12 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +10 -0
- package/dist/events.js.map +1 -1
- package/dist/index.d.ts +9 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/sandbox-providers.d.ts +435 -0
- package/dist/sandbox-providers.d.ts.map +1 -0
- package/dist/sandbox-providers.js +1344 -0
- package/dist/sandbox-providers.js.map +1 -0
- package/dist/sandbox-tools.d.ts +13 -0
- package/dist/sandbox-tools.d.ts.map +1 -0
- package/dist/sandbox-tools.js +104 -0
- package/dist/sandbox-tools.js.map +1 -0
- package/dist/sandbox.d.ts +28 -5
- package/dist/sandbox.d.ts.map +1 -1
- package/dist/sandbox.js +110 -4
- package/dist/sandbox.js.map +1 -1
- package/dist/skills.d.ts +83 -0
- package/dist/skills.d.ts.map +1 -0
- package/dist/skills.js +281 -0
- package/dist/skills.js.map +1 -0
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -5
|
@@ -0,0 +1,1344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Managed sandbox provider integrations.
|
|
3
|
+
*
|
|
4
|
+
* Native fetch-based clients for external sandbox vendors, conforming to
|
|
5
|
+
* the canonical Rust contract in `sdk-core/src/sandbox/providers/`:
|
|
6
|
+
*
|
|
7
|
+
* - {@link E2BSandboxProvider} — E2B (api.e2b.app + envd/code-interpreter)
|
|
8
|
+
* - {@link DaytonaSandboxProvider} — Daytona (app.daytona.io + toolbox proxy)
|
|
9
|
+
* - {@link VercelSandboxProvider} — Vercel Sandbox (api.vercel.com /v2/sandboxes)
|
|
10
|
+
* - {@link NorthflankSandboxProvider} — Northflank (REST + websocket exec; needs Node >= 22 for the global WebSocket)
|
|
11
|
+
* - {@link TogetherSandboxProvider} — Together Code Interpreter (/v1/tci)
|
|
12
|
+
*
|
|
13
|
+
* Modal is not included here: its API is gRPC-only and is integrated
|
|
14
|
+
* natively in the Rust core (sdk-core); a TS surface for it would need
|
|
15
|
+
* gRPC bindings rather than fetch.
|
|
16
|
+
*
|
|
17
|
+
* Provider sandboxes expose the same data-plane surface as {@link Sandbox}
|
|
18
|
+
* (executeCode, runCommand, writeFile, readFile, deleteFile, listFiles,
|
|
19
|
+
* health) plus provider extras (preview URLs, Daytona git operations).
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts
|
|
23
|
+
* import { E2BSandboxProvider } from '@agnt5/sdk';
|
|
24
|
+
*
|
|
25
|
+
* const provider = E2BSandboxProvider.fromEnv();
|
|
26
|
+
* const sandbox = await provider.create({ timeoutSecs: 300 });
|
|
27
|
+
* const result = await sandbox.executeCode('print(6 * 7)', 'python');
|
|
28
|
+
* console.log(result.stdout); // "42"
|
|
29
|
+
* await provider.destroy(sandbox.sandboxId);
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
import { gzipSync } from 'node:zlib';
|
|
33
|
+
/** Error from a sandbox provider API. */
|
|
34
|
+
export class SandboxProviderError extends Error {
|
|
35
|
+
constructor(provider, operation, message) {
|
|
36
|
+
super(`${provider} ${operation}: ${message}`);
|
|
37
|
+
this.provider = provider;
|
|
38
|
+
this.operation = operation;
|
|
39
|
+
this.name = 'SandboxProviderError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// ── Shared helpers ─────────────────────────────────────────────
|
|
43
|
+
const INTERPRETERS = {
|
|
44
|
+
python: ['python3', '-c'],
|
|
45
|
+
javascript: ['node', '-e'],
|
|
46
|
+
bash: ['bash', '-c'],
|
|
47
|
+
};
|
|
48
|
+
function interpreterArgv(language, code) {
|
|
49
|
+
const entry = INTERPRETERS[language];
|
|
50
|
+
if (!entry) {
|
|
51
|
+
throw new SandboxProviderError('sandbox', 'executeCode', `unsupported language: ${language}`);
|
|
52
|
+
}
|
|
53
|
+
return [entry[0], [entry[1], code]];
|
|
54
|
+
}
|
|
55
|
+
/** Quote a string for safe interpolation into a POSIX shell command line. */
|
|
56
|
+
function shellQuote(s) {
|
|
57
|
+
return `'${s.replace(/'/g, `'"'"'`)}'`;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Parse `type|size|mode|mtime|path` directory-listing lines (the output of
|
|
61
|
+
* GNU `find -printf '%y|%s|%m|%T@|%p\n'`).
|
|
62
|
+
*/
|
|
63
|
+
function parseListingOutput(stdout) {
|
|
64
|
+
const files = [];
|
|
65
|
+
for (const line of stdout.split('\n')) {
|
|
66
|
+
const parts = line.split('|');
|
|
67
|
+
if (parts.length < 5)
|
|
68
|
+
continue;
|
|
69
|
+
const path = parts.slice(4).join('|');
|
|
70
|
+
files.push({
|
|
71
|
+
name: path.split('/').pop() ?? path,
|
|
72
|
+
path,
|
|
73
|
+
size: Number(parts[1]) || 0,
|
|
74
|
+
mode: parseInt(parts[2], 8) || 0,
|
|
75
|
+
isDir: parts[0] === 'd',
|
|
76
|
+
modTime: Math.round((Number(parts[3]) || 0) * 1000),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return files;
|
|
80
|
+
}
|
|
81
|
+
async function check(provider, operation, resp) {
|
|
82
|
+
if (!resp.ok) {
|
|
83
|
+
const body = (await resp.text().catch(() => '')).slice(0, 500);
|
|
84
|
+
throw new SandboxProviderError(provider, operation, `HTTP ${resp.status} — ${body}`);
|
|
85
|
+
}
|
|
86
|
+
return resp;
|
|
87
|
+
}
|
|
88
|
+
function requireEnv(provider, name) {
|
|
89
|
+
const value = process.env[name];
|
|
90
|
+
if (!value) {
|
|
91
|
+
throw new SandboxProviderError(provider, 'fromEnv', `${name} is required`);
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Control plane for E2B sandboxes (https://e2b.dev).
|
|
97
|
+
*
|
|
98
|
+
* Code/command execution uses the code-interpreter data plane (port 49999),
|
|
99
|
+
* so the default template is `code-interpreter-v1`. File operations use
|
|
100
|
+
* envd (port 49983) and work on any template.
|
|
101
|
+
*/
|
|
102
|
+
export class E2BSandboxProvider {
|
|
103
|
+
constructor(options) {
|
|
104
|
+
this.name = 'e2b';
|
|
105
|
+
this.apiKey = options.apiKey;
|
|
106
|
+
this.domain = options.domain ?? 'e2b.app';
|
|
107
|
+
this.apiUrl = options.apiUrl ?? `https://api.${this.domain}`;
|
|
108
|
+
this.template = options.template ?? 'code-interpreter-v1';
|
|
109
|
+
}
|
|
110
|
+
/** Build from E2B_API_KEY (+ optional E2B_DOMAIN, E2B_API_URL, E2B_TEMPLATE). */
|
|
111
|
+
static fromEnv() {
|
|
112
|
+
return new E2BSandboxProvider({
|
|
113
|
+
apiKey: requireEnv('e2b', 'E2B_API_KEY'),
|
|
114
|
+
domain: process.env.E2B_DOMAIN,
|
|
115
|
+
apiUrl: process.env.E2B_API_URL,
|
|
116
|
+
template: process.env.E2B_TEMPLATE,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
headers() {
|
|
120
|
+
return { 'X-API-Key': this.apiKey, 'Content-Type': 'application/json' };
|
|
121
|
+
}
|
|
122
|
+
handle(data) {
|
|
123
|
+
return new E2BSandbox(data.sandboxID, data.domain ?? this.domain, data.envdAccessToken);
|
|
124
|
+
}
|
|
125
|
+
async create(opts = {}) {
|
|
126
|
+
const body = {
|
|
127
|
+
templateID: opts.template ?? this.template,
|
|
128
|
+
timeout: opts.timeoutSecs ?? 300,
|
|
129
|
+
};
|
|
130
|
+
if (opts.env)
|
|
131
|
+
body.envVars = opts.env;
|
|
132
|
+
if (opts.metadata)
|
|
133
|
+
body.metadata = opts.metadata;
|
|
134
|
+
const resp = await check('e2b', 'create', await fetch(`${this.apiUrl}/sandboxes`, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: this.headers(),
|
|
137
|
+
body: JSON.stringify(body),
|
|
138
|
+
}));
|
|
139
|
+
return this.handle((await resp.json()));
|
|
140
|
+
}
|
|
141
|
+
/** Connect to an existing sandbox, resuming it if paused. */
|
|
142
|
+
async connect(sandboxId) {
|
|
143
|
+
const resp = await check('e2b', 'connect', await fetch(`${this.apiUrl}/sandboxes/${sandboxId}/connect`, {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
headers: this.headers(),
|
|
146
|
+
body: JSON.stringify({ timeout: 300 }),
|
|
147
|
+
}));
|
|
148
|
+
return this.handle((await resp.json()));
|
|
149
|
+
}
|
|
150
|
+
async destroy(sandboxId) {
|
|
151
|
+
await check('e2b', 'destroy', await fetch(`${this.apiUrl}/sandboxes/${sandboxId}`, {
|
|
152
|
+
method: 'DELETE',
|
|
153
|
+
headers: this.headers(),
|
|
154
|
+
}));
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
async listSandboxes() {
|
|
158
|
+
const resp = await check('e2b', 'listSandboxes', await fetch(`${this.apiUrl}/sandboxes`, { headers: this.headers() }));
|
|
159
|
+
const items = (await resp.json());
|
|
160
|
+
return items.map((item) => ({
|
|
161
|
+
sandboxId: item.sandboxID ?? '',
|
|
162
|
+
status: item.state ?? 'running',
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
/** Extend the sandbox lifetime to `timeoutSecs` from now. */
|
|
166
|
+
async setTimeout(sandboxId, timeoutSecs) {
|
|
167
|
+
await check('e2b', 'setTimeout', await fetch(`${this.apiUrl}/sandboxes/${sandboxId}/timeout`, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: this.headers(),
|
|
170
|
+
body: JSON.stringify({ timeout: timeoutSecs }),
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** A running E2B sandbox. */
|
|
175
|
+
export class E2BSandbox {
|
|
176
|
+
constructor(sandboxId, domain = 'e2b.app', envdAccessToken) {
|
|
177
|
+
this.sandboxId = sandboxId;
|
|
178
|
+
this.domain = domain;
|
|
179
|
+
this.envdAccessToken = envdAccessToken;
|
|
180
|
+
this.languages = ['python', 'javascript', 'bash'];
|
|
181
|
+
this.envdUrl = `https://49983-${sandboxId}.${domain}`;
|
|
182
|
+
this.interpreterUrl = `https://49999-${sandboxId}.${domain}`;
|
|
183
|
+
}
|
|
184
|
+
/** Public URL for a port inside the sandbox (no API call required). */
|
|
185
|
+
previewUrl(port) {
|
|
186
|
+
return `https://${port}-${this.sandboxId}.${this.domain}`;
|
|
187
|
+
}
|
|
188
|
+
envdHeaders(extra = {}) {
|
|
189
|
+
// envd selects the OS user via Basic auth with an empty password.
|
|
190
|
+
const headers = {
|
|
191
|
+
Authorization: `Basic ${Buffer.from('user:').toString('base64')}`,
|
|
192
|
+
...extra,
|
|
193
|
+
};
|
|
194
|
+
if (this.envdAccessToken)
|
|
195
|
+
headers['X-Access-Token'] = this.envdAccessToken;
|
|
196
|
+
return headers;
|
|
197
|
+
}
|
|
198
|
+
async executeRaw(code, language, env, timeoutMs) {
|
|
199
|
+
const body = { code, language };
|
|
200
|
+
if (env)
|
|
201
|
+
body.env_vars = env;
|
|
202
|
+
const started = Date.now();
|
|
203
|
+
const resp = await check('e2b', 'executeCode', await fetch(`${this.interpreterUrl}/execute`, {
|
|
204
|
+
method: 'POST',
|
|
205
|
+
headers: this.envdHeaders({ 'Content-Type': 'application/json' }),
|
|
206
|
+
body: JSON.stringify(body),
|
|
207
|
+
signal: AbortSignal.timeout(timeoutMs + 30000),
|
|
208
|
+
}));
|
|
209
|
+
let stdout = '';
|
|
210
|
+
let stderr = '';
|
|
211
|
+
let error;
|
|
212
|
+
for (const line of (await resp.text()).split('\n')) {
|
|
213
|
+
const trimmed = line.trim();
|
|
214
|
+
if (!trimmed)
|
|
215
|
+
continue;
|
|
216
|
+
let event;
|
|
217
|
+
try {
|
|
218
|
+
event = JSON.parse(trimmed);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (event.type === 'stdout')
|
|
224
|
+
stdout += event.text ?? '';
|
|
225
|
+
else if (event.type === 'stderr')
|
|
226
|
+
stderr += event.text ?? '';
|
|
227
|
+
else if (event.type === 'error')
|
|
228
|
+
error = `${event.name ?? 'Error'}: ${event.value ?? ''}`;
|
|
229
|
+
}
|
|
230
|
+
return { stdout, stderr, error, elapsedMs: Date.now() - started };
|
|
231
|
+
}
|
|
232
|
+
async executeCode(code, language = 'python', options = {}) {
|
|
233
|
+
let effective = code;
|
|
234
|
+
if (options.workDir && language === 'bash') {
|
|
235
|
+
effective = `cd ${shellQuote(options.workDir)} && ${code}`;
|
|
236
|
+
}
|
|
237
|
+
const { stdout, stderr, error, elapsedMs } = await this.executeRaw(effective, language, options.env, options.timeoutMs ?? 30000);
|
|
238
|
+
return { stdout, stderr, exitCode: error ? 1 : 0, executionTimeMs: elapsedMs, error };
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Run a shell command via the code interpreter's bash kernel.
|
|
242
|
+
* Requires a code-interpreter template; exit codes are synthesized.
|
|
243
|
+
*/
|
|
244
|
+
async runCommand(command, options = {}) {
|
|
245
|
+
let line = '';
|
|
246
|
+
if (options.workingDir)
|
|
247
|
+
line += `cd ${shellQuote(options.workingDir)} && `;
|
|
248
|
+
line += command;
|
|
249
|
+
for (const arg of options.args ?? [])
|
|
250
|
+
line += ` ${shellQuote(arg)}`;
|
|
251
|
+
const { stdout, stderr, error, elapsedMs } = await this.executeRaw(line, 'bash', options.env, options.timeoutMs ?? 30000);
|
|
252
|
+
return { stdout, stderr, exitCode: error ? 1 : 0, executionTimeMs: elapsedMs, error };
|
|
253
|
+
}
|
|
254
|
+
async health() {
|
|
255
|
+
await check('e2b', 'health', await fetch(`${this.envdUrl}/health`, { headers: this.envdHeaders() }));
|
|
256
|
+
return { status: 'running', sandboxId: this.sandboxId, uptimeMs: 0, backendKind: 'remote' };
|
|
257
|
+
}
|
|
258
|
+
async writeFile(path, content) {
|
|
259
|
+
const data = typeof content === 'string' ? Buffer.from(content) : content;
|
|
260
|
+
const params = new URLSearchParams({ path, username: 'user' });
|
|
261
|
+
await check('e2b', 'writeFile', await fetch(`${this.envdUrl}/files?${params}`, {
|
|
262
|
+
method: 'POST',
|
|
263
|
+
headers: this.envdHeaders({ 'Content-Type': 'application/octet-stream' }),
|
|
264
|
+
body: new Uint8Array(data),
|
|
265
|
+
}));
|
|
266
|
+
return { success: true, path, size: data.length };
|
|
267
|
+
}
|
|
268
|
+
async readFile(path) {
|
|
269
|
+
const params = new URLSearchParams({ path, username: 'user' });
|
|
270
|
+
const resp = await check('e2b', 'readFile', await fetch(`${this.envdUrl}/files?${params}`, { headers: this.envdHeaders() }));
|
|
271
|
+
const content = Buffer.from(await resp.arrayBuffer());
|
|
272
|
+
return { path, content, size: content.length, isDir: false };
|
|
273
|
+
}
|
|
274
|
+
/** Unary Connect-RPC call to envd's filesystem service (JSON encoding). */
|
|
275
|
+
async filesystemRpc(method, body) {
|
|
276
|
+
const resp = await check('e2b', method, await fetch(`${this.envdUrl}/filesystem.Filesystem/${method}`, {
|
|
277
|
+
method: 'POST',
|
|
278
|
+
headers: this.envdHeaders({ 'Content-Type': 'application/json' }),
|
|
279
|
+
body: JSON.stringify(body),
|
|
280
|
+
}));
|
|
281
|
+
return resp.json();
|
|
282
|
+
}
|
|
283
|
+
async deleteFile(path, _recursive = false) {
|
|
284
|
+
// envd's Remove deletes files and directories alike.
|
|
285
|
+
await this.filesystemRpc('Remove', { path });
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
async listFiles(path, recursive = false) {
|
|
289
|
+
const data = await this.filesystemRpc('ListDir', { path, depth: recursive ? 64 : 1 });
|
|
290
|
+
const files = (data.entries ?? []).map((entry) => ({
|
|
291
|
+
name: entry.name ?? '',
|
|
292
|
+
path: entry.path ?? '',
|
|
293
|
+
// proto3 JSON serializes 64-bit ints as strings.
|
|
294
|
+
size: Number(entry.size) || 0,
|
|
295
|
+
mode: Number(entry.mode) || 0,
|
|
296
|
+
isDir: entry.type === 'FILE_TYPE_DIRECTORY',
|
|
297
|
+
modTime: entry.modifiedTime ? Date.parse(entry.modifiedTime) || 0 : 0,
|
|
298
|
+
}));
|
|
299
|
+
return { path, files, total: files.length };
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/** Control plane for Daytona sandboxes (https://daytona.io). */
|
|
303
|
+
export class DaytonaSandboxProvider {
|
|
304
|
+
constructor(options) {
|
|
305
|
+
this.name = 'daytona';
|
|
306
|
+
this.apiKey = options.apiKey;
|
|
307
|
+
this.apiUrl = (options.apiUrl ?? 'https://app.daytona.io/api').replace(/\/$/, '');
|
|
308
|
+
this.target = options.target;
|
|
309
|
+
this.readyTimeoutSecs = options.readyTimeoutSecs ?? 120;
|
|
310
|
+
}
|
|
311
|
+
/** Build from DAYTONA_API_KEY (+ optional DAYTONA_API_URL, DAYTONA_TARGET). */
|
|
312
|
+
static fromEnv() {
|
|
313
|
+
return new DaytonaSandboxProvider({
|
|
314
|
+
apiKey: requireEnv('daytona', 'DAYTONA_API_KEY'),
|
|
315
|
+
apiUrl: process.env.DAYTONA_API_URL,
|
|
316
|
+
target: process.env.DAYTONA_TARGET,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
headers() {
|
|
320
|
+
return { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' };
|
|
321
|
+
}
|
|
322
|
+
async getSandbox(sandboxId) {
|
|
323
|
+
const resp = await check('daytona', 'getSandbox', await fetch(`${this.apiUrl}/sandbox/${sandboxId}`, { headers: this.headers() }));
|
|
324
|
+
return resp.json();
|
|
325
|
+
}
|
|
326
|
+
async handle(data) {
|
|
327
|
+
let proxyUrl = data.toolboxProxyUrl;
|
|
328
|
+
if (!proxyUrl) {
|
|
329
|
+
const resp = await check('daytona', 'toolboxProxyUrl', await fetch(`${this.apiUrl}/sandbox/${data.id}/toolbox-proxy-url`, {
|
|
330
|
+
headers: this.headers(),
|
|
331
|
+
}));
|
|
332
|
+
proxyUrl = (await resp.json()).url;
|
|
333
|
+
}
|
|
334
|
+
return new DaytonaSandbox(data.id, `${proxyUrl.replace(/\/$/, '')}/${data.id}`, this.apiUrl, this.headers());
|
|
335
|
+
}
|
|
336
|
+
async create(opts = {}) {
|
|
337
|
+
const body = {};
|
|
338
|
+
if (opts.template)
|
|
339
|
+
body.snapshot = opts.template;
|
|
340
|
+
if (opts.env)
|
|
341
|
+
body.env = opts.env;
|
|
342
|
+
if (opts.metadata)
|
|
343
|
+
body.labels = opts.metadata;
|
|
344
|
+
if (opts.cpuCores)
|
|
345
|
+
body.cpu = opts.cpuCores;
|
|
346
|
+
if (opts.memoryMib)
|
|
347
|
+
body.memory = Math.ceil(opts.memoryMib / 1024); // GB
|
|
348
|
+
if (opts.timeoutSecs)
|
|
349
|
+
body.autoStopInterval = Math.ceil(opts.timeoutSecs / 60); // minutes
|
|
350
|
+
if (this.target)
|
|
351
|
+
body.target = this.target;
|
|
352
|
+
const resp = await check('daytona', 'create', await fetch(`${this.apiUrl}/sandbox`, {
|
|
353
|
+
method: 'POST',
|
|
354
|
+
headers: this.headers(),
|
|
355
|
+
body: JSON.stringify(body),
|
|
356
|
+
}));
|
|
357
|
+
let sandbox = (await resp.json());
|
|
358
|
+
const deadline = Date.now() + this.readyTimeoutSecs * 1000;
|
|
359
|
+
while (sandbox.state !== 'started') {
|
|
360
|
+
if (DaytonaSandboxProvider.FAILED_STATES.has(sandbox.state)) {
|
|
361
|
+
throw new SandboxProviderError('daytona', 'create', `sandbox ${sandbox.id} entered state '${sandbox.state}'`);
|
|
362
|
+
}
|
|
363
|
+
if (Date.now() >= deadline) {
|
|
364
|
+
throw new SandboxProviderError('daytona', 'create', `sandbox ${sandbox.id} not started in ${this.readyTimeoutSecs}s`);
|
|
365
|
+
}
|
|
366
|
+
await new Promise((r) => global.setTimeout(r, 2000));
|
|
367
|
+
sandbox = await this.getSandbox(sandbox.id);
|
|
368
|
+
}
|
|
369
|
+
return this.handle(sandbox);
|
|
370
|
+
}
|
|
371
|
+
async connect(sandboxId) {
|
|
372
|
+
return this.handle(await this.getSandbox(sandboxId));
|
|
373
|
+
}
|
|
374
|
+
async destroy(sandboxId) {
|
|
375
|
+
await check('daytona', 'destroy', await fetch(`${this.apiUrl}/sandbox/${sandboxId}`, {
|
|
376
|
+
method: 'DELETE',
|
|
377
|
+
headers: this.headers(),
|
|
378
|
+
}));
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
async listSandboxes() {
|
|
382
|
+
const resp = await check('daytona', 'listSandboxes', await fetch(`${this.apiUrl}/sandbox`, { headers: this.headers() }));
|
|
383
|
+
const data = (await resp.json());
|
|
384
|
+
return (data.items ?? []).map((s) => ({
|
|
385
|
+
sandboxId: s.id ?? '',
|
|
386
|
+
status: s.state ?? 'unknown',
|
|
387
|
+
}));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
DaytonaSandboxProvider.FAILED_STATES = new Set([
|
|
391
|
+
'error',
|
|
392
|
+
'build_failed',
|
|
393
|
+
'destroyed',
|
|
394
|
+
'destroying',
|
|
395
|
+
]);
|
|
396
|
+
/**
|
|
397
|
+
* A running Daytona sandbox, driven via its toolbox daemon.
|
|
398
|
+
*
|
|
399
|
+
* Note: Daytona's exec API merges stdout and stderr; combined output is
|
|
400
|
+
* reported as stdout.
|
|
401
|
+
*/
|
|
402
|
+
export class DaytonaSandbox {
|
|
403
|
+
constructor(sandboxId, toolboxUrl, apiUrl, authHeaders) {
|
|
404
|
+
this.sandboxId = sandboxId;
|
|
405
|
+
this.toolboxUrl = toolboxUrl;
|
|
406
|
+
this.apiUrl = apiUrl;
|
|
407
|
+
this.authHeaders = authHeaders;
|
|
408
|
+
this.languages = ['python', 'javascript', 'bash'];
|
|
409
|
+
}
|
|
410
|
+
async runCommand(command, options = {}) {
|
|
411
|
+
let line = command;
|
|
412
|
+
for (const arg of options.args ?? [])
|
|
413
|
+
line += ` ${shellQuote(arg)}`;
|
|
414
|
+
const timeoutMs = options.timeoutMs ?? 30000;
|
|
415
|
+
const body = {
|
|
416
|
+
command: line,
|
|
417
|
+
timeout: Math.max(1, Math.floor(timeoutMs / 1000)),
|
|
418
|
+
};
|
|
419
|
+
if (options.workingDir)
|
|
420
|
+
body.cwd = options.workingDir;
|
|
421
|
+
if (options.env)
|
|
422
|
+
body.envs = options.env;
|
|
423
|
+
const started = Date.now();
|
|
424
|
+
const resp = await check('daytona', 'runCommand', await fetch(`${this.toolboxUrl}/process/execute`, {
|
|
425
|
+
method: 'POST',
|
|
426
|
+
headers: this.authHeaders,
|
|
427
|
+
body: JSON.stringify(body),
|
|
428
|
+
signal: AbortSignal.timeout(timeoutMs + 30000),
|
|
429
|
+
}));
|
|
430
|
+
const data = (await resp.json());
|
|
431
|
+
return {
|
|
432
|
+
stdout: data.result ?? '',
|
|
433
|
+
stderr: '',
|
|
434
|
+
exitCode: data.exitCode ?? -1,
|
|
435
|
+
executionTimeMs: Date.now() - started,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
async executeCode(code, language = 'python', options = {}) {
|
|
439
|
+
const timeoutMs = options.timeoutMs ?? 30000;
|
|
440
|
+
if (language === 'bash') {
|
|
441
|
+
// Bash has no native code-run language; go through the shell.
|
|
442
|
+
const [program, args] = interpreterArgv(language, code);
|
|
443
|
+
const result = await this.runCommand(program, {
|
|
444
|
+
args,
|
|
445
|
+
workingDir: options.workDir,
|
|
446
|
+
env: options.env,
|
|
447
|
+
timeoutMs,
|
|
448
|
+
});
|
|
449
|
+
return {
|
|
450
|
+
stdout: result.stdout,
|
|
451
|
+
stderr: result.stderr,
|
|
452
|
+
exitCode: result.exitCode,
|
|
453
|
+
executionTimeMs: result.executionTimeMs,
|
|
454
|
+
error: result.error,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
const body = {
|
|
458
|
+
code,
|
|
459
|
+
language,
|
|
460
|
+
timeout: Math.max(1, Math.floor(timeoutMs / 1000)),
|
|
461
|
+
};
|
|
462
|
+
if (options.env)
|
|
463
|
+
body.envs = options.env;
|
|
464
|
+
const started = Date.now();
|
|
465
|
+
const resp = await check('daytona', 'executeCode', await fetch(`${this.toolboxUrl}/process/code-run`, {
|
|
466
|
+
method: 'POST',
|
|
467
|
+
headers: this.authHeaders,
|
|
468
|
+
body: JSON.stringify(body),
|
|
469
|
+
signal: AbortSignal.timeout(timeoutMs + 30000),
|
|
470
|
+
}));
|
|
471
|
+
const data = (await resp.json());
|
|
472
|
+
return {
|
|
473
|
+
stdout: data.result ?? '',
|
|
474
|
+
stderr: '',
|
|
475
|
+
exitCode: data.exitCode ?? -1,
|
|
476
|
+
executionTimeMs: Date.now() - started,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
async health() {
|
|
480
|
+
const resp = await check('daytona', 'health', await fetch(`${this.apiUrl}/sandbox/${this.sandboxId}`, { headers: this.authHeaders }));
|
|
481
|
+
const data = (await resp.json());
|
|
482
|
+
return {
|
|
483
|
+
status: data.state ?? 'unknown',
|
|
484
|
+
sandboxId: this.sandboxId,
|
|
485
|
+
uptimeMs: 0,
|
|
486
|
+
backendKind: 'remote',
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
async writeFile(path, content) {
|
|
490
|
+
const data = typeof content === 'string' ? Buffer.from(content) : content;
|
|
491
|
+
const form = new FormData();
|
|
492
|
+
form.append('file', new Blob([new Uint8Array(data)]), path.split('/').pop() ?? 'file');
|
|
493
|
+
const params = new URLSearchParams({ path });
|
|
494
|
+
const headers = { ...this.authHeaders };
|
|
495
|
+
delete headers['Content-Type']; // FormData sets its own boundary
|
|
496
|
+
await check('daytona', 'writeFile', await fetch(`${this.toolboxUrl}/files/upload?${params}`, {
|
|
497
|
+
method: 'POST',
|
|
498
|
+
headers,
|
|
499
|
+
body: form,
|
|
500
|
+
}));
|
|
501
|
+
return { success: true, path, size: data.length };
|
|
502
|
+
}
|
|
503
|
+
async readFile(path) {
|
|
504
|
+
const params = new URLSearchParams({ path });
|
|
505
|
+
const resp = await check('daytona', 'readFile', await fetch(`${this.toolboxUrl}/files/download?${params}`, { headers: this.authHeaders }));
|
|
506
|
+
const content = Buffer.from(await resp.arrayBuffer());
|
|
507
|
+
return { path, content, size: content.length, isDir: false };
|
|
508
|
+
}
|
|
509
|
+
async deleteFile(path, recursive = false) {
|
|
510
|
+
const params = new URLSearchParams({ path, recursive: String(recursive) });
|
|
511
|
+
await check('daytona', 'deleteFile', await fetch(`${this.toolboxUrl}/files?${params}`, {
|
|
512
|
+
method: 'DELETE',
|
|
513
|
+
headers: this.authHeaders,
|
|
514
|
+
}));
|
|
515
|
+
return true;
|
|
516
|
+
}
|
|
517
|
+
async listFiles(path, recursive = false) {
|
|
518
|
+
if (recursive) {
|
|
519
|
+
// The toolbox files API lists one directory; recurse via find.
|
|
520
|
+
const result = await this.runCommand(`find ${shellQuote(path)} -mindepth 1 -printf '%y|%s|%m|%T@|%p\\n'`);
|
|
521
|
+
if (result.exitCode !== 0) {
|
|
522
|
+
throw new SandboxProviderError('daytona', 'listFiles', result.stdout);
|
|
523
|
+
}
|
|
524
|
+
const files = parseListingOutput(result.stdout);
|
|
525
|
+
return { path, files, total: files.length };
|
|
526
|
+
}
|
|
527
|
+
const params = new URLSearchParams({ path });
|
|
528
|
+
const resp = await check('daytona', 'listFiles', await fetch(`${this.toolboxUrl}/files?${params}`, { headers: this.authHeaders }));
|
|
529
|
+
const entries = (await resp.json());
|
|
530
|
+
const files = entries.map((entry) => {
|
|
531
|
+
let mode = 0;
|
|
532
|
+
for (const candidate of [entry.permissions, entry.mode]) {
|
|
533
|
+
if (candidate && /^[0-7]+$/.test(String(candidate))) {
|
|
534
|
+
mode = parseInt(String(candidate), 8);
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return {
|
|
539
|
+
name: entry.name ?? '',
|
|
540
|
+
path: `${path.replace(/\/$/, '')}/${entry.name ?? ''}`,
|
|
541
|
+
size: entry.size ?? 0,
|
|
542
|
+
mode,
|
|
543
|
+
isDir: entry.isDir ?? false,
|
|
544
|
+
modTime: entry.modifiedAt ? Date.parse(entry.modifiedAt) || 0 : 0,
|
|
545
|
+
};
|
|
546
|
+
});
|
|
547
|
+
return { path, files, total: files.length };
|
|
548
|
+
}
|
|
549
|
+
/** Public preview URL + access token for a sandbox port. */
|
|
550
|
+
async previewUrl(port) {
|
|
551
|
+
const resp = await check('daytona', 'previewUrl', await fetch(`${this.apiUrl}/sandbox/${this.sandboxId}/ports/${port}/preview-url`, {
|
|
552
|
+
headers: this.authHeaders,
|
|
553
|
+
}));
|
|
554
|
+
return resp.json();
|
|
555
|
+
}
|
|
556
|
+
// ----- Git (native toolbox endpoints) -----
|
|
557
|
+
async gitClone(url, options = {}) {
|
|
558
|
+
const path = options.targetDir ??
|
|
559
|
+
(url.replace(/\/$/, '').split('/').pop() ?? 'repo').replace(/\.git$/, '');
|
|
560
|
+
const body = { url, path };
|
|
561
|
+
if (options.branch)
|
|
562
|
+
body.branch = options.branch;
|
|
563
|
+
if (options.username)
|
|
564
|
+
body.username = options.username;
|
|
565
|
+
if (options.password)
|
|
566
|
+
body.password = options.password;
|
|
567
|
+
await check('daytona', 'gitClone', await fetch(`${this.toolboxUrl}/git/clone`, {
|
|
568
|
+
method: 'POST',
|
|
569
|
+
headers: this.authHeaders,
|
|
570
|
+
body: JSON.stringify(body),
|
|
571
|
+
}));
|
|
572
|
+
return { success: true, path, branch: options.branch ?? '' };
|
|
573
|
+
}
|
|
574
|
+
async gitCommit(message, options = {}) {
|
|
575
|
+
const path = options.path ?? '.';
|
|
576
|
+
await check('daytona', 'gitAdd', await fetch(`${this.toolboxUrl}/git/add`, {
|
|
577
|
+
method: 'POST',
|
|
578
|
+
headers: this.authHeaders,
|
|
579
|
+
body: JSON.stringify({ path, files: options.files ?? ['.'] }),
|
|
580
|
+
}));
|
|
581
|
+
// The toolbox requires separate author name and email fields.
|
|
582
|
+
const author = options.author ?? 'AGNT5 <agnt5@agnt5.dev>';
|
|
583
|
+
const match = author.match(/^(.*?)<(.*)>$/);
|
|
584
|
+
const name = (match?.[1] ?? author).trim();
|
|
585
|
+
const email = (match?.[2] ?? 'agnt5@agnt5.dev').trim();
|
|
586
|
+
const resp = await check('daytona', 'gitCommit', await fetch(`${this.toolboxUrl}/git/commit`, {
|
|
587
|
+
method: 'POST',
|
|
588
|
+
headers: this.authHeaders,
|
|
589
|
+
body: JSON.stringify({ path, message, author: name, email }),
|
|
590
|
+
}));
|
|
591
|
+
const data = (await resp.json().catch(() => ({})));
|
|
592
|
+
return { success: true, commitSha: data.hash ?? '' };
|
|
593
|
+
}
|
|
594
|
+
async gitPush(options = {}) {
|
|
595
|
+
const body = { path: options.path ?? '.' };
|
|
596
|
+
if (options.username)
|
|
597
|
+
body.username = options.username;
|
|
598
|
+
if (options.password)
|
|
599
|
+
body.password = options.password;
|
|
600
|
+
await check('daytona', 'gitPush', await fetch(`${this.toolboxUrl}/git/push`, {
|
|
601
|
+
method: 'POST',
|
|
602
|
+
headers: this.authHeaders,
|
|
603
|
+
body: JSON.stringify(body),
|
|
604
|
+
}));
|
|
605
|
+
return { success: true };
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Control plane for Vercel Sandboxes (https://vercel.com/docs/sandbox).
|
|
610
|
+
*
|
|
611
|
+
* Auth modes (matching `@vercel/sandbox`): an OIDC token alone, or an
|
|
612
|
+
* access token + teamId + projectId.
|
|
613
|
+
*/
|
|
614
|
+
export class VercelSandboxProvider {
|
|
615
|
+
constructor(options) {
|
|
616
|
+
this.name = 'vercel';
|
|
617
|
+
this.token = options.token;
|
|
618
|
+
this.teamId = options.teamId;
|
|
619
|
+
this.projectId = options.projectId;
|
|
620
|
+
this.baseUrl = (options.baseUrl ?? 'https://api.vercel.com').replace(/\/$/, '');
|
|
621
|
+
}
|
|
622
|
+
/** Build from VERCEL_OIDC_TOKEN, or VERCEL_TOKEN + VERCEL_TEAM_ID + VERCEL_PROJECT_ID. */
|
|
623
|
+
static fromEnv() {
|
|
624
|
+
const baseUrl = process.env.VERCEL_SANDBOX_BASE_URL;
|
|
625
|
+
const oidc = process.env.VERCEL_OIDC_TOKEN;
|
|
626
|
+
if (oidc) {
|
|
627
|
+
return new VercelSandboxProvider({
|
|
628
|
+
token: oidc,
|
|
629
|
+
teamId: process.env.VERCEL_TEAM_ID,
|
|
630
|
+
projectId: process.env.VERCEL_PROJECT_ID,
|
|
631
|
+
baseUrl,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
const token = requireEnv('vercel', 'VERCEL_TOKEN');
|
|
635
|
+
const teamId = process.env.VERCEL_TEAM_ID;
|
|
636
|
+
const projectId = process.env.VERCEL_PROJECT_ID;
|
|
637
|
+
if (!teamId || !projectId) {
|
|
638
|
+
throw new SandboxProviderError('vercel', 'fromEnv', 'VERCEL_TEAM_ID and VERCEL_PROJECT_ID are required with VERCEL_TOKEN');
|
|
639
|
+
}
|
|
640
|
+
return new VercelSandboxProvider({ token, teamId, projectId, baseUrl });
|
|
641
|
+
}
|
|
642
|
+
headers() {
|
|
643
|
+
return { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json' };
|
|
644
|
+
}
|
|
645
|
+
params(extra = {}) {
|
|
646
|
+
const params = new URLSearchParams(extra);
|
|
647
|
+
if (this.teamId)
|
|
648
|
+
params.set('teamId', this.teamId);
|
|
649
|
+
if (this.projectId)
|
|
650
|
+
params.set('projectId', this.projectId);
|
|
651
|
+
const s = params.toString();
|
|
652
|
+
return s ? `?${s}` : '';
|
|
653
|
+
}
|
|
654
|
+
handle(data) {
|
|
655
|
+
return new VercelSandbox(data.sandbox?.name ?? '', data.session.id, data.routes ?? [], this.baseUrl, this.params(), this.headers());
|
|
656
|
+
}
|
|
657
|
+
async create(opts = {}) {
|
|
658
|
+
const body = {
|
|
659
|
+
name: `agnt5-${crypto.randomUUID().replace(/-/g, '')}`,
|
|
660
|
+
runtime: opts.template ?? 'node24',
|
|
661
|
+
timeout: (opts.timeoutSecs ?? 300) * 1000,
|
|
662
|
+
};
|
|
663
|
+
if (opts.cpuCores)
|
|
664
|
+
body.resources = { vcpus: opts.cpuCores };
|
|
665
|
+
if (opts.env)
|
|
666
|
+
body.env = opts.env;
|
|
667
|
+
if (this.projectId)
|
|
668
|
+
body.projectId = this.projectId;
|
|
669
|
+
const resp = await check('vercel', 'create', await fetch(`${this.baseUrl}/v2/sandboxes${this.params()}`, {
|
|
670
|
+
method: 'POST',
|
|
671
|
+
headers: this.headers(),
|
|
672
|
+
body: JSON.stringify(body),
|
|
673
|
+
}));
|
|
674
|
+
return this.handle(await resp.json());
|
|
675
|
+
}
|
|
676
|
+
/** Connect to an existing sandbox by name, resuming it if stopped. */
|
|
677
|
+
async connect(name) {
|
|
678
|
+
const resp = await check('vercel', 'connect', await fetch(`${this.baseUrl}/v2/sandboxes/${name}${this.params({ resume: 'true' })}`, {
|
|
679
|
+
headers: this.headers(),
|
|
680
|
+
}));
|
|
681
|
+
return this.handle(await resp.json());
|
|
682
|
+
}
|
|
683
|
+
async destroy(name) {
|
|
684
|
+
await check('vercel', 'destroy', await fetch(`${this.baseUrl}/v2/sandboxes/${name}${this.params()}`, {
|
|
685
|
+
method: 'DELETE',
|
|
686
|
+
headers: this.headers(),
|
|
687
|
+
}));
|
|
688
|
+
return true;
|
|
689
|
+
}
|
|
690
|
+
async listSandboxes() {
|
|
691
|
+
const resp = await check('vercel', 'listSandboxes', await fetch(`${this.baseUrl}/v2/sandboxes${this.params()}`, { headers: this.headers() }));
|
|
692
|
+
const data = (await resp.json());
|
|
693
|
+
return (data.sandboxes ?? []).map((s) => ({
|
|
694
|
+
sandboxId: s.name ?? '',
|
|
695
|
+
status: s.status ?? 'unknown',
|
|
696
|
+
}));
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
/** A running Vercel Sandbox session. */
|
|
700
|
+
export class VercelSandbox {
|
|
701
|
+
constructor(name, sessionId, routes, baseUrl, query, authHeaders) {
|
|
702
|
+
this.name = name;
|
|
703
|
+
this.sessionId = sessionId;
|
|
704
|
+
this.routes = routes;
|
|
705
|
+
this.baseUrl = baseUrl;
|
|
706
|
+
this.query = query;
|
|
707
|
+
this.authHeaders = authHeaders;
|
|
708
|
+
this.languages = ['python', 'javascript', 'bash'];
|
|
709
|
+
}
|
|
710
|
+
get sandboxId() {
|
|
711
|
+
return this.name;
|
|
712
|
+
}
|
|
713
|
+
/** Public URL for a port declared at sandbox creation. */
|
|
714
|
+
previewUrl(port) {
|
|
715
|
+
return this.routes.find((r) => r.port === port)?.url;
|
|
716
|
+
}
|
|
717
|
+
sessionUrl(suffix = '') {
|
|
718
|
+
return `${this.baseUrl}/v2/sandboxes/sessions/${this.sessionId}${suffix}${this.query}`;
|
|
719
|
+
}
|
|
720
|
+
async runCommand(command, options = {}) {
|
|
721
|
+
const timeoutMs = options.timeoutMs ?? 30000;
|
|
722
|
+
const body = {
|
|
723
|
+
command,
|
|
724
|
+
args: options.args ?? [],
|
|
725
|
+
wait: true,
|
|
726
|
+
logs: true,
|
|
727
|
+
timeout: timeoutMs,
|
|
728
|
+
};
|
|
729
|
+
if (options.workingDir)
|
|
730
|
+
body.cwd = options.workingDir;
|
|
731
|
+
if (options.env)
|
|
732
|
+
body.env = options.env;
|
|
733
|
+
if (options.sudo)
|
|
734
|
+
body.sudo = true;
|
|
735
|
+
const started = Date.now();
|
|
736
|
+
const resp = await check('vercel', 'runCommand', await fetch(this.sessionUrl('/cmd'), {
|
|
737
|
+
method: 'POST',
|
|
738
|
+
headers: this.authHeaders,
|
|
739
|
+
body: JSON.stringify(body),
|
|
740
|
+
signal: AbortSignal.timeout(timeoutMs + 30000),
|
|
741
|
+
}));
|
|
742
|
+
let stdout = '';
|
|
743
|
+
let stderr = '';
|
|
744
|
+
let exitCode = -1;
|
|
745
|
+
let error;
|
|
746
|
+
// ND-JSON stream: log lines carry stream/data; command lines carry exitCode.
|
|
747
|
+
for (const line of (await resp.text()).split('\n')) {
|
|
748
|
+
const trimmed = line.trim();
|
|
749
|
+
if (!trimmed)
|
|
750
|
+
continue;
|
|
751
|
+
let event;
|
|
752
|
+
try {
|
|
753
|
+
event = JSON.parse(trimmed);
|
|
754
|
+
}
|
|
755
|
+
catch {
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
if (event.stream === 'stdout' && typeof event.data === 'string')
|
|
759
|
+
stdout += event.data;
|
|
760
|
+
else if (event.stream === 'stderr' && typeof event.data === 'string')
|
|
761
|
+
stderr += event.data;
|
|
762
|
+
else if (event.stream === 'error') {
|
|
763
|
+
error = typeof event.data === 'object' ? event.data?.message : String(event.data);
|
|
764
|
+
}
|
|
765
|
+
if (event.command && event.command.exitCode !== null && event.command.exitCode !== undefined) {
|
|
766
|
+
exitCode = event.command.exitCode;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
return { stdout, stderr, exitCode, executionTimeMs: Date.now() - started, error };
|
|
770
|
+
}
|
|
771
|
+
async executeCode(code, language = 'python', options = {}) {
|
|
772
|
+
const [program, args] = interpreterArgv(language, code);
|
|
773
|
+
const result = await this.runCommand(program, {
|
|
774
|
+
args,
|
|
775
|
+
workingDir: options.workDir,
|
|
776
|
+
env: options.env,
|
|
777
|
+
timeoutMs: options.timeoutMs,
|
|
778
|
+
});
|
|
779
|
+
return {
|
|
780
|
+
stdout: result.stdout,
|
|
781
|
+
stderr: result.stderr,
|
|
782
|
+
exitCode: result.exitCode,
|
|
783
|
+
executionTimeMs: result.executionTimeMs,
|
|
784
|
+
error: result.error,
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
async health() {
|
|
788
|
+
const resp = await check('vercel', 'health', await fetch(this.sessionUrl(), { headers: this.authHeaders }));
|
|
789
|
+
const data = (await resp.json());
|
|
790
|
+
return {
|
|
791
|
+
status: data.status ?? data.session?.status ?? 'unknown',
|
|
792
|
+
sandboxId: this.name,
|
|
793
|
+
uptimeMs: 0,
|
|
794
|
+
backendKind: 'remote',
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
async writeFile(path, content, mode = 0o644) {
|
|
798
|
+
const data = typeof content === 'string' ? Buffer.from(content) : content;
|
|
799
|
+
// fs/write takes a gzipped tar; entry paths resolve against x-cwd.
|
|
800
|
+
const [cwd, entry] = path.startsWith('/') ? ['/', path.slice(1)] : ['/vercel/sandbox', path];
|
|
801
|
+
const archive = gzipSync(buildTar(entry, data, mode));
|
|
802
|
+
await check('vercel', 'writeFile', await fetch(this.sessionUrl('/fs/write'), {
|
|
803
|
+
method: 'POST',
|
|
804
|
+
headers: {
|
|
805
|
+
...this.authHeaders,
|
|
806
|
+
'Content-Type': 'application/gzip',
|
|
807
|
+
'x-cwd': cwd,
|
|
808
|
+
},
|
|
809
|
+
body: new Uint8Array(archive),
|
|
810
|
+
}));
|
|
811
|
+
return { success: true, path, size: data.length };
|
|
812
|
+
}
|
|
813
|
+
async readFile(path) {
|
|
814
|
+
const resp = await check('vercel', 'readFile', await fetch(this.sessionUrl('/fs/read'), {
|
|
815
|
+
method: 'POST',
|
|
816
|
+
headers: this.authHeaders,
|
|
817
|
+
body: JSON.stringify({ path }),
|
|
818
|
+
}));
|
|
819
|
+
const content = Buffer.from(await resp.arrayBuffer());
|
|
820
|
+
return { path, content, size: content.length, isDir: false };
|
|
821
|
+
}
|
|
822
|
+
async deleteFile(path, recursive = false) {
|
|
823
|
+
const args = ['-f', ...(recursive ? ['-r'] : []), '--', path];
|
|
824
|
+
const result = await this.runCommand('rm', { args });
|
|
825
|
+
return result.exitCode === 0;
|
|
826
|
+
}
|
|
827
|
+
async listFiles(path, recursive = false) {
|
|
828
|
+
// Amazon Linux 2023 ships GNU findutils, so -printf is available.
|
|
829
|
+
const args = [path, '-mindepth', '1'];
|
|
830
|
+
if (!recursive)
|
|
831
|
+
args.push('-maxdepth', '1');
|
|
832
|
+
args.push('-printf', '%y|%s|%m|%T@|%p\\n');
|
|
833
|
+
const result = await this.runCommand('find', { args });
|
|
834
|
+
if (result.exitCode !== 0) {
|
|
835
|
+
throw new SandboxProviderError('vercel', 'listFiles', result.stderr);
|
|
836
|
+
}
|
|
837
|
+
const files = parseListingOutput(result.stdout);
|
|
838
|
+
return { path, files, total: files.length };
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
/** Build a single-file POSIX (ustar) tar archive in memory. */
|
|
842
|
+
function buildTar(entryPath, content, mode) {
|
|
843
|
+
const header = Buffer.alloc(512);
|
|
844
|
+
header.write(entryPath, 0, 100, 'utf8'); // name
|
|
845
|
+
header.write(mode.toString(8).padStart(7, '0'), 100, 8); // mode
|
|
846
|
+
header.write('0000000', 108, 8); // uid
|
|
847
|
+
header.write('0000000', 116, 8); // gid
|
|
848
|
+
header.write(content.length.toString(8).padStart(11, '0'), 124, 12); // size
|
|
849
|
+
header.write(Math.floor(Date.now() / 1000).toString(8).padStart(11, '0'), 136, 12); // mtime
|
|
850
|
+
header.write(' ', 148, 8); // checksum placeholder (spaces)
|
|
851
|
+
header.write('0', 156, 1); // typeflag: regular file
|
|
852
|
+
header.write('ustar', 257, 6); // magic
|
|
853
|
+
header.write('00', 263, 2); // version
|
|
854
|
+
let checksum = 0;
|
|
855
|
+
for (const byte of header)
|
|
856
|
+
checksum += byte;
|
|
857
|
+
header.write(checksum.toString(8).padStart(6, '0') + '\0 ', 148, 8);
|
|
858
|
+
const padding = Buffer.alloc((512 - (content.length % 512)) % 512);
|
|
859
|
+
const trailer = Buffer.alloc(1024); // two zero blocks
|
|
860
|
+
return Buffer.concat([header, content, padding, trailer]);
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Control plane for Northflank sandboxes (https://northflank.com).
|
|
864
|
+
*
|
|
865
|
+
* Sandboxes are deployment services running `sleep infinity`; commands run
|
|
866
|
+
* over the command-exec websocket (requires the global `WebSocket`,
|
|
867
|
+
* available in Node >= 22). File operations are emulated over exec.
|
|
868
|
+
*/
|
|
869
|
+
export class NorthflankSandboxProvider {
|
|
870
|
+
constructor(options) {
|
|
871
|
+
this.name = 'northflank';
|
|
872
|
+
this.apiToken = options.apiToken;
|
|
873
|
+
this.projectId = options.projectId;
|
|
874
|
+
this.teamId = options.teamId;
|
|
875
|
+
this.baseUrl = (options.baseUrl ?? 'https://api.northflank.com').replace(/\/$/, '');
|
|
876
|
+
this.deploymentPlan = options.deploymentPlan ?? 'nf-compute-200';
|
|
877
|
+
this.image = options.image ?? 'python:3.12-slim-bookworm';
|
|
878
|
+
this.readyTimeoutSecs = options.readyTimeoutSecs ?? 180;
|
|
879
|
+
}
|
|
880
|
+
/** Build from NORTHFLANK_API_TOKEN + NORTHFLANK_PROJECT_ID (+ optional vars). */
|
|
881
|
+
static fromEnv() {
|
|
882
|
+
return new NorthflankSandboxProvider({
|
|
883
|
+
apiToken: requireEnv('northflank', 'NORTHFLANK_API_TOKEN'),
|
|
884
|
+
projectId: requireEnv('northflank', 'NORTHFLANK_PROJECT_ID'),
|
|
885
|
+
teamId: process.env.NORTHFLANK_TEAM_ID,
|
|
886
|
+
baseUrl: process.env.NORTHFLANK_API_URL,
|
|
887
|
+
deploymentPlan: process.env.NORTHFLANK_DEPLOYMENT_PLAN,
|
|
888
|
+
image: process.env.NORTHFLANK_SANDBOX_IMAGE,
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
headers() {
|
|
892
|
+
return { Authorization: `Bearer ${this.apiToken}`, 'Content-Type': 'application/json' };
|
|
893
|
+
}
|
|
894
|
+
serviceUrl(serviceId) {
|
|
895
|
+
return `${this.baseUrl}/v1/projects/${this.projectId}/services/${serviceId}`;
|
|
896
|
+
}
|
|
897
|
+
/** Extract a human-readable deployment status from the service status blob. */
|
|
898
|
+
static deploymentStatus(status) {
|
|
899
|
+
if (!status || typeof status !== 'object')
|
|
900
|
+
return 'unknown';
|
|
901
|
+
const deployment = status.deployment ?? status;
|
|
902
|
+
if (deployment && typeof deployment === 'object') {
|
|
903
|
+
return String(deployment.status ?? JSON.stringify(deployment));
|
|
904
|
+
}
|
|
905
|
+
return String(deployment);
|
|
906
|
+
}
|
|
907
|
+
handle(serviceId) {
|
|
908
|
+
return new NorthflankSandbox(serviceId, this.projectId, this.apiToken, this.teamId, this.baseUrl, this.headers());
|
|
909
|
+
}
|
|
910
|
+
async create(opts = {}) {
|
|
911
|
+
const body = {
|
|
912
|
+
name: `agnt5-${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`,
|
|
913
|
+
billing: { deploymentPlan: this.deploymentPlan },
|
|
914
|
+
deployment: {
|
|
915
|
+
external: { imagePath: opts.template ?? this.image },
|
|
916
|
+
docker: { configType: 'customCommand', customCommand: 'sleep infinity' },
|
|
917
|
+
},
|
|
918
|
+
};
|
|
919
|
+
if (opts.env)
|
|
920
|
+
body.runtimeEnvironment = opts.env;
|
|
921
|
+
const resp = await check('northflank', 'create', await fetch(`${this.baseUrl}/v1/projects/${this.projectId}/services/deployment`, {
|
|
922
|
+
method: 'POST',
|
|
923
|
+
headers: this.headers(),
|
|
924
|
+
body: JSON.stringify(body),
|
|
925
|
+
}));
|
|
926
|
+
const serviceId = (await resp.json()).data.id;
|
|
927
|
+
const deadline = Date.now() + this.readyTimeoutSecs * 1000;
|
|
928
|
+
for (;;) {
|
|
929
|
+
const statusResp = await check('northflank', 'getService', await fetch(this.serviceUrl(serviceId), { headers: this.headers() }));
|
|
930
|
+
const data = (await statusResp.json()).data;
|
|
931
|
+
const status = NorthflankSandboxProvider.deploymentStatus(data.status).toLowerCase();
|
|
932
|
+
if (status.includes('running') || status.includes('completed'))
|
|
933
|
+
break;
|
|
934
|
+
if (status.includes('failed') || status.includes('error')) {
|
|
935
|
+
throw new SandboxProviderError('northflank', 'create', `service ${serviceId} failed (${status})`);
|
|
936
|
+
}
|
|
937
|
+
if (Date.now() >= deadline) {
|
|
938
|
+
throw new SandboxProviderError('northflank', 'create', `service ${serviceId} not running in ${this.readyTimeoutSecs}s (${status})`);
|
|
939
|
+
}
|
|
940
|
+
await new Promise((r) => global.setTimeout(r, 3000));
|
|
941
|
+
}
|
|
942
|
+
return this.handle(serviceId);
|
|
943
|
+
}
|
|
944
|
+
async connect(serviceId) {
|
|
945
|
+
await check('northflank', 'connect', await fetch(this.serviceUrl(serviceId), { headers: this.headers() }));
|
|
946
|
+
return this.handle(serviceId);
|
|
947
|
+
}
|
|
948
|
+
async destroy(serviceId) {
|
|
949
|
+
await check('northflank', 'destroy', await fetch(this.serviceUrl(serviceId), { method: 'DELETE', headers: this.headers() }));
|
|
950
|
+
return true;
|
|
951
|
+
}
|
|
952
|
+
async listSandboxes() {
|
|
953
|
+
const resp = await check('northflank', 'listSandboxes', await fetch(`${this.baseUrl}/v1/projects/${this.projectId}/services`, {
|
|
954
|
+
headers: this.headers(),
|
|
955
|
+
}));
|
|
956
|
+
const data = (await resp.json());
|
|
957
|
+
return (data.data?.services ?? []).map((s) => ({
|
|
958
|
+
sandboxId: s.id ?? '',
|
|
959
|
+
status: NorthflankSandboxProvider.deploymentStatus(s.status),
|
|
960
|
+
}));
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
/** A running Northflank sandbox service. */
|
|
964
|
+
export class NorthflankSandbox {
|
|
965
|
+
constructor(serviceId, projectId, apiToken, teamId, baseUrl, authHeaders) {
|
|
966
|
+
this.serviceId = serviceId;
|
|
967
|
+
this.projectId = projectId;
|
|
968
|
+
this.apiToken = apiToken;
|
|
969
|
+
this.teamId = teamId;
|
|
970
|
+
this.baseUrl = baseUrl;
|
|
971
|
+
this.authHeaders = authHeaders;
|
|
972
|
+
this.languages = ['python', 'javascript', 'bash'];
|
|
973
|
+
}
|
|
974
|
+
get sandboxId() {
|
|
975
|
+
return this.serviceId;
|
|
976
|
+
}
|
|
977
|
+
/** Build the exec websocket URL, including the team prefix when present. */
|
|
978
|
+
wsUrl() {
|
|
979
|
+
const wsBase = this.baseUrl.replace(/^https:\/\//, 'wss://').replace(/^http:\/\//, 'ws://');
|
|
980
|
+
const team = this.teamId ? `teams/${this.teamId}/` : '';
|
|
981
|
+
return `${wsBase}/v1/command-exec/${team}projects/${this.projectId}/services/${this.serviceId}`;
|
|
982
|
+
}
|
|
983
|
+
/** Run a command over the command-exec websocket. */
|
|
984
|
+
async runCommand(command, options = {}) {
|
|
985
|
+
if (typeof WebSocket === 'undefined') {
|
|
986
|
+
throw new SandboxProviderError('northflank', 'runCommand', 'the global WebSocket API is required for Northflank exec (Node >= 22)');
|
|
987
|
+
}
|
|
988
|
+
let argv = [command, ...(options.args ?? [])];
|
|
989
|
+
if (options.workingDir || options.env) {
|
|
990
|
+
// The exec context has no cwd/env parameters; wrap in a shell.
|
|
991
|
+
let line = '';
|
|
992
|
+
if (options.workingDir)
|
|
993
|
+
line += `cd ${shellQuote(options.workingDir)} && `;
|
|
994
|
+
for (const [key, value] of Object.entries(options.env ?? {})) {
|
|
995
|
+
line += `export ${key}=${shellQuote(value)} && `;
|
|
996
|
+
}
|
|
997
|
+
line += argv.map(shellQuote).join(' ');
|
|
998
|
+
argv = ['bash', '-c', line];
|
|
999
|
+
}
|
|
1000
|
+
const timeoutMs = options.timeoutMs ?? 30000;
|
|
1001
|
+
const started = Date.now();
|
|
1002
|
+
return new Promise((resolve, reject) => {
|
|
1003
|
+
const ws = new WebSocket(this.wsUrl());
|
|
1004
|
+
let stdout = '';
|
|
1005
|
+
let stderr = '';
|
|
1006
|
+
let exitCode = -1;
|
|
1007
|
+
let settled = false;
|
|
1008
|
+
const timer = global.setTimeout(() => {
|
|
1009
|
+
fail(new SandboxProviderError('northflank', 'runCommand', `timed out after ${timeoutMs} ms`));
|
|
1010
|
+
}, timeoutMs);
|
|
1011
|
+
const finish = () => {
|
|
1012
|
+
if (settled)
|
|
1013
|
+
return;
|
|
1014
|
+
settled = true;
|
|
1015
|
+
global.clearTimeout(timer);
|
|
1016
|
+
ws.close();
|
|
1017
|
+
resolve({ stdout, stderr, exitCode, executionTimeMs: Date.now() - started });
|
|
1018
|
+
};
|
|
1019
|
+
const fail = (err) => {
|
|
1020
|
+
if (settled)
|
|
1021
|
+
return;
|
|
1022
|
+
settled = true;
|
|
1023
|
+
global.clearTimeout(timer);
|
|
1024
|
+
ws.close();
|
|
1025
|
+
reject(err);
|
|
1026
|
+
};
|
|
1027
|
+
ws.onopen = () => {
|
|
1028
|
+
// Auth is in-band: the first message carries the API token.
|
|
1029
|
+
ws.send(JSON.stringify({
|
|
1030
|
+
type: 'init',
|
|
1031
|
+
data: {
|
|
1032
|
+
auth: { type: 'apiToken', apiToken: this.apiToken },
|
|
1033
|
+
context: { command: argv },
|
|
1034
|
+
},
|
|
1035
|
+
}));
|
|
1036
|
+
};
|
|
1037
|
+
ws.onmessage = (event) => {
|
|
1038
|
+
let msg;
|
|
1039
|
+
try {
|
|
1040
|
+
msg = JSON.parse(String(event.data));
|
|
1041
|
+
}
|
|
1042
|
+
catch {
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
const data = msg.data;
|
|
1046
|
+
switch (msg.type) {
|
|
1047
|
+
case 'init': {
|
|
1048
|
+
const auth = typeof data === 'object' ? data?.auth : data;
|
|
1049
|
+
if (auth !== 'successful') {
|
|
1050
|
+
fail(new SandboxProviderError('northflank', 'runCommand', `auth failed: ${JSON.stringify(msg)}`));
|
|
1051
|
+
}
|
|
1052
|
+
break;
|
|
1053
|
+
}
|
|
1054
|
+
case 'stdOut':
|
|
1055
|
+
if (typeof data === 'string')
|
|
1056
|
+
stdout += data;
|
|
1057
|
+
break;
|
|
1058
|
+
case 'stdErr':
|
|
1059
|
+
if (typeof data === 'string')
|
|
1060
|
+
stderr += data;
|
|
1061
|
+
break;
|
|
1062
|
+
case 'completion':
|
|
1063
|
+
if (data && typeof data.exitCode === 'number')
|
|
1064
|
+
exitCode = data.exitCode;
|
|
1065
|
+
finish();
|
|
1066
|
+
break;
|
|
1067
|
+
case 'error':
|
|
1068
|
+
fail(new SandboxProviderError('northflank', 'runCommand', typeof data === 'object' ? (data?.message ?? 'unknown error') : String(data)));
|
|
1069
|
+
break;
|
|
1070
|
+
}
|
|
1071
|
+
};
|
|
1072
|
+
ws.onerror = () => fail(new SandboxProviderError('northflank', 'runCommand', 'websocket error'));
|
|
1073
|
+
ws.onclose = () => finish();
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
async executeCode(code, language = 'python', options = {}) {
|
|
1077
|
+
const [program, args] = interpreterArgv(language, code);
|
|
1078
|
+
const result = await this.runCommand(program, {
|
|
1079
|
+
args,
|
|
1080
|
+
workingDir: options.workDir,
|
|
1081
|
+
env: options.env,
|
|
1082
|
+
timeoutMs: options.timeoutMs,
|
|
1083
|
+
});
|
|
1084
|
+
return {
|
|
1085
|
+
stdout: result.stdout,
|
|
1086
|
+
stderr: result.stderr,
|
|
1087
|
+
exitCode: result.exitCode,
|
|
1088
|
+
executionTimeMs: result.executionTimeMs,
|
|
1089
|
+
error: result.error,
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
async health() {
|
|
1093
|
+
const resp = await check('northflank', 'health', await fetch(`${this.baseUrl}/v1/projects/${this.projectId}/services/${this.serviceId}`, {
|
|
1094
|
+
headers: this.authHeaders,
|
|
1095
|
+
}));
|
|
1096
|
+
const data = (await resp.json());
|
|
1097
|
+
return {
|
|
1098
|
+
status: NorthflankSandboxProvider.deploymentStatus(data.data?.status),
|
|
1099
|
+
sandboxId: this.serviceId,
|
|
1100
|
+
uptimeMs: 0,
|
|
1101
|
+
backendKind: 'remote',
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
async writeFile(path, content, mode = 0o644) {
|
|
1105
|
+
const data = typeof content === 'string' ? Buffer.from(content) : content;
|
|
1106
|
+
const encoded = data.toString('base64');
|
|
1107
|
+
const parent = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) || '/' : '.';
|
|
1108
|
+
const script = `mkdir -p ${shellQuote(parent)} && ` +
|
|
1109
|
+
`printf '%s' ${shellQuote(encoded)} | base64 -d > ${shellQuote(path)} && ` +
|
|
1110
|
+
`chmod ${mode.toString(8)} ${shellQuote(path)}`;
|
|
1111
|
+
const result = await this.runCommand('bash', { args: ['-c', script], timeoutMs: 60000 });
|
|
1112
|
+
if (result.exitCode !== 0) {
|
|
1113
|
+
throw new SandboxProviderError('northflank', 'writeFile', result.stderr);
|
|
1114
|
+
}
|
|
1115
|
+
return { success: true, path, size: data.length };
|
|
1116
|
+
}
|
|
1117
|
+
async readFile(path) {
|
|
1118
|
+
const result = await this.runCommand('base64', { args: [path], timeoutMs: 60000 });
|
|
1119
|
+
if (result.exitCode !== 0) {
|
|
1120
|
+
throw new SandboxProviderError('northflank', 'readFile', result.stderr);
|
|
1121
|
+
}
|
|
1122
|
+
const content = Buffer.from(result.stdout.replace(/\s/g, ''), 'base64');
|
|
1123
|
+
return { path, content, size: content.length, isDir: false };
|
|
1124
|
+
}
|
|
1125
|
+
async deleteFile(path, recursive = false) {
|
|
1126
|
+
const args = ['-f', ...(recursive ? ['-r'] : []), '--', path];
|
|
1127
|
+
const result = await this.runCommand('rm', { args });
|
|
1128
|
+
return result.exitCode === 0;
|
|
1129
|
+
}
|
|
1130
|
+
async listFiles(path, recursive = false) {
|
|
1131
|
+
const args = [path, '-mindepth', '1'];
|
|
1132
|
+
if (!recursive)
|
|
1133
|
+
args.push('-maxdepth', '1');
|
|
1134
|
+
args.push('-printf', '%y|%s|%m|%T@|%p\\n');
|
|
1135
|
+
const result = await this.runCommand('find', { args });
|
|
1136
|
+
if (result.exitCode !== 0) {
|
|
1137
|
+
throw new SandboxProviderError('northflank', 'listFiles', result.stderr);
|
|
1138
|
+
}
|
|
1139
|
+
const files = parseListingOutput(result.stdout);
|
|
1140
|
+
return { path, files, total: files.length };
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Control plane for Together Code Interpreter sessions (https://together.ai).
|
|
1145
|
+
*
|
|
1146
|
+
* Sessions persist packages/variables for 60 minutes and cannot be
|
|
1147
|
+
* destroyed (they expire on their own). Only Python execution is
|
|
1148
|
+
* supported; file operations are emulated via session-side snippets and
|
|
1149
|
+
* TCI's native files upload.
|
|
1150
|
+
*/
|
|
1151
|
+
export class TogetherSandboxProvider {
|
|
1152
|
+
constructor(options) {
|
|
1153
|
+
this.name = 'together';
|
|
1154
|
+
this.apiKey = options.apiKey;
|
|
1155
|
+
this.baseUrl = (options.baseUrl ?? 'https://api.together.ai').replace(/\/$/, '');
|
|
1156
|
+
}
|
|
1157
|
+
/** Build from TOGETHER_API_KEY (+ optional TOGETHER_BASE_URL). */
|
|
1158
|
+
static fromEnv() {
|
|
1159
|
+
return new TogetherSandboxProvider({
|
|
1160
|
+
apiKey: requireEnv('together', 'TOGETHER_API_KEY'),
|
|
1161
|
+
baseUrl: process.env.TOGETHER_BASE_URL,
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
headers() {
|
|
1165
|
+
return { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' };
|
|
1166
|
+
}
|
|
1167
|
+
/** @internal Execute code in a TCI session, optionally uploading files first. */
|
|
1168
|
+
async execute(code, sessionId, files, timeoutMs = 60000) {
|
|
1169
|
+
const body = { code, language: 'python' };
|
|
1170
|
+
if (sessionId)
|
|
1171
|
+
body.session_id = sessionId;
|
|
1172
|
+
if (files)
|
|
1173
|
+
body.files = files;
|
|
1174
|
+
const resp = await check('together', 'execute', await fetch(`${this.baseUrl}/v1/tci/execute`, {
|
|
1175
|
+
method: 'POST',
|
|
1176
|
+
headers: this.headers(),
|
|
1177
|
+
body: JSON.stringify(body),
|
|
1178
|
+
signal: AbortSignal.timeout(timeoutMs + 30000),
|
|
1179
|
+
}));
|
|
1180
|
+
const parsed = (await resp.json());
|
|
1181
|
+
if (parsed.errors && (!Array.isArray(parsed.errors) || parsed.errors.length > 0)) {
|
|
1182
|
+
throw new SandboxProviderError('together', 'execute', JSON.stringify(parsed.errors));
|
|
1183
|
+
}
|
|
1184
|
+
if (!parsed.data) {
|
|
1185
|
+
throw new SandboxProviderError('together', 'execute', 'response missing data');
|
|
1186
|
+
}
|
|
1187
|
+
return parsed.data;
|
|
1188
|
+
}
|
|
1189
|
+
/** @internal */
|
|
1190
|
+
async sessions() {
|
|
1191
|
+
const resp = await check('together', 'sessions', await fetch(`${this.baseUrl}/v1/tci/sessions`, { headers: this.headers() }));
|
|
1192
|
+
const parsed = (await resp.json());
|
|
1193
|
+
return parsed.data?.sessions ?? [];
|
|
1194
|
+
}
|
|
1195
|
+
async create(_opts = {}) {
|
|
1196
|
+
// Sessions are created implicitly; a no-op execution materializes one.
|
|
1197
|
+
const data = await this.execute('pass');
|
|
1198
|
+
if (!data.session_id) {
|
|
1199
|
+
throw new SandboxProviderError('together', 'create', 'no session_id returned');
|
|
1200
|
+
}
|
|
1201
|
+
return new TogetherSandbox(data.session_id, this);
|
|
1202
|
+
}
|
|
1203
|
+
async connect(sessionId) {
|
|
1204
|
+
const sessions = await this.sessions();
|
|
1205
|
+
if (!sessions.some((s) => s.id === sessionId)) {
|
|
1206
|
+
throw new SandboxProviderError('together', 'connect', `session '${sessionId}' not found or expired`);
|
|
1207
|
+
}
|
|
1208
|
+
return new TogetherSandbox(sessionId, this);
|
|
1209
|
+
}
|
|
1210
|
+
async destroy(_sessionId) {
|
|
1211
|
+
throw new SandboxProviderError('together', 'destroy', 'TCI sessions cannot be destroyed; they expire after 60 minutes');
|
|
1212
|
+
}
|
|
1213
|
+
async listSandboxes() {
|
|
1214
|
+
return (await this.sessions()).map((s) => ({ sandboxId: s.id ?? '', status: 'running' }));
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
/** A live Together Code Interpreter session. */
|
|
1218
|
+
export class TogetherSandbox {
|
|
1219
|
+
constructor(sessionId, provider) {
|
|
1220
|
+
this.sessionId = sessionId;
|
|
1221
|
+
this.provider = provider;
|
|
1222
|
+
this.languages = ['python'];
|
|
1223
|
+
}
|
|
1224
|
+
get sandboxId() {
|
|
1225
|
+
return this.sessionId;
|
|
1226
|
+
}
|
|
1227
|
+
/** Map TCI outputs onto stdout/stderr/error. */
|
|
1228
|
+
static mapOutputs(data) {
|
|
1229
|
+
let stdout = '';
|
|
1230
|
+
let stderr = '';
|
|
1231
|
+
let error;
|
|
1232
|
+
for (const output of data.outputs ?? []) {
|
|
1233
|
+
const value = output.data;
|
|
1234
|
+
if (output.type === 'stdout' && typeof value === 'string')
|
|
1235
|
+
stdout += value;
|
|
1236
|
+
else if (output.type === 'stderr' && typeof value === 'string')
|
|
1237
|
+
stderr += value;
|
|
1238
|
+
else if (output.type === 'error')
|
|
1239
|
+
error = typeof value === 'string' ? value : JSON.stringify(value);
|
|
1240
|
+
else if (value && typeof value === 'object' && 'text/plain' in value) {
|
|
1241
|
+
stdout += String(value['text/plain']);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
const succeeded = !error && (data.status === 'success' || data.status === 'completed');
|
|
1245
|
+
return { stdout, stderr, exitCode: succeeded ? 0 : 1, executionTimeMs: 0, error };
|
|
1246
|
+
}
|
|
1247
|
+
async executeCode(code, language = 'python', options = {}) {
|
|
1248
|
+
if (language !== 'python') {
|
|
1249
|
+
throw new SandboxProviderError('together', 'executeCode', `Together Code Interpreter only supports Python (requested: ${language})`);
|
|
1250
|
+
}
|
|
1251
|
+
const started = Date.now();
|
|
1252
|
+
const data = await this.provider.execute(code, this.sessionId, undefined, options.timeoutMs);
|
|
1253
|
+
const result = TogetherSandbox.mapOutputs(data);
|
|
1254
|
+
result.executionTimeMs = Date.now() - started;
|
|
1255
|
+
return result;
|
|
1256
|
+
}
|
|
1257
|
+
async runPython(code, operation) {
|
|
1258
|
+
const data = await this.provider.execute(code, this.sessionId);
|
|
1259
|
+
const result = TogetherSandbox.mapOutputs(data);
|
|
1260
|
+
if (result.error) {
|
|
1261
|
+
throw new SandboxProviderError('together', operation, result.error);
|
|
1262
|
+
}
|
|
1263
|
+
return result;
|
|
1264
|
+
}
|
|
1265
|
+
async health() {
|
|
1266
|
+
const sessions = await this.provider.sessions();
|
|
1267
|
+
const alive = sessions.some((s) => s.id === this.sessionId);
|
|
1268
|
+
return {
|
|
1269
|
+
status: alive ? 'running' : 'expired',
|
|
1270
|
+
sandboxId: this.sessionId,
|
|
1271
|
+
uptimeMs: 0,
|
|
1272
|
+
backendKind: 'remote',
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
async writeFile(path, content) {
|
|
1276
|
+
const data = typeof content === 'string' ? Buffer.from(content) : content;
|
|
1277
|
+
await this.provider.execute('pass', this.sessionId, [
|
|
1278
|
+
{ name: path, encoding: 'base64', content: data.toString('base64') },
|
|
1279
|
+
]);
|
|
1280
|
+
return { success: true, path, size: data.length };
|
|
1281
|
+
}
|
|
1282
|
+
async readFile(path) {
|
|
1283
|
+
const code = `import base64, sys\n` +
|
|
1284
|
+
`sys.stdout.write(base64.b64encode(open(${JSON.stringify(path)}, 'rb').read()).decode())`;
|
|
1285
|
+
const result = await this.runPython(code, 'readFile');
|
|
1286
|
+
const content = Buffer.from(result.stdout.replace(/\s/g, ''), 'base64');
|
|
1287
|
+
return { path, content, size: content.length, isDir: false };
|
|
1288
|
+
}
|
|
1289
|
+
async deleteFile(path, recursive = false) {
|
|
1290
|
+
const code = `import os, shutil\n` +
|
|
1291
|
+
`p = ${JSON.stringify(path)}\n` +
|
|
1292
|
+
`if os.path.isdir(p) and not os.path.islink(p):\n` +
|
|
1293
|
+
` shutil.rmtree(p) if ${recursive ? 'True' : 'False'} else os.rmdir(p)\n` +
|
|
1294
|
+
`else:\n` +
|
|
1295
|
+
` os.remove(p)`;
|
|
1296
|
+
await this.runPython(code, 'deleteFile');
|
|
1297
|
+
return true;
|
|
1298
|
+
}
|
|
1299
|
+
async listFiles(path, recursive = false) {
|
|
1300
|
+
const code = `import os\n` +
|
|
1301
|
+
`p = ${JSON.stringify(path)}\n` +
|
|
1302
|
+
`entries = []\n` +
|
|
1303
|
+
`if ${recursive ? 'True' : 'False'}:\n` +
|
|
1304
|
+
` for dirpath, dirnames, filenames in os.walk(p):\n` +
|
|
1305
|
+
` entries.extend(os.path.join(dirpath, n) for n in dirnames + filenames)\n` +
|
|
1306
|
+
`else:\n` +
|
|
1307
|
+
` entries = [os.path.join(p, n) for n in os.listdir(p)]\n` +
|
|
1308
|
+
`for e in entries:\n` +
|
|
1309
|
+
` st = os.lstat(e)\n` +
|
|
1310
|
+
` kind = 'd' if os.path.isdir(e) and not os.path.islink(e) else 'f'\n` +
|
|
1311
|
+
` print(f"{kind}|{st.st_size}|{oct(st.st_mode & 0o7777)[2:]}|{st.st_mtime}|{e}")`;
|
|
1312
|
+
const result = await this.runPython(code, 'listFiles');
|
|
1313
|
+
const files = parseListingOutput(result.stdout);
|
|
1314
|
+
return { path, files, total: files.length };
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* Detect and construct providers from environment variables.
|
|
1319
|
+
*
|
|
1320
|
+
* Mirrors the Rust `SandboxRegistry::load_providers_from_environment`:
|
|
1321
|
+
* a partially configured provider throws rather than being silently
|
|
1322
|
+
* skipped. Construction is lazy — no network calls are made.
|
|
1323
|
+
*
|
|
1324
|
+
* Triggers: E2B_API_KEY, DAYTONA_API_KEY, VERCEL_OIDC_TOKEN/VERCEL_TOKEN,
|
|
1325
|
+
* NORTHFLANK_API_TOKEN, TOGETHER_API_KEY.
|
|
1326
|
+
*/
|
|
1327
|
+
export function loadProvidersFromEnv() {
|
|
1328
|
+
const providers = {};
|
|
1329
|
+
if (process.env.E2B_API_KEY)
|
|
1330
|
+
providers.e2b = E2BSandboxProvider.fromEnv();
|
|
1331
|
+
if (process.env.DAYTONA_API_KEY)
|
|
1332
|
+
providers.daytona = DaytonaSandboxProvider.fromEnv();
|
|
1333
|
+
if (process.env.VERCEL_OIDC_TOKEN || process.env.VERCEL_TOKEN) {
|
|
1334
|
+
providers.vercel = VercelSandboxProvider.fromEnv();
|
|
1335
|
+
}
|
|
1336
|
+
if (process.env.NORTHFLANK_API_TOKEN)
|
|
1337
|
+
providers.northflank = NorthflankSandboxProvider.fromEnv();
|
|
1338
|
+
if (process.env.TOGETHER_API_KEY)
|
|
1339
|
+
providers.together = TogetherSandboxProvider.fromEnv();
|
|
1340
|
+
return providers;
|
|
1341
|
+
}
|
|
1342
|
+
// Internal helpers exported for unit tests.
|
|
1343
|
+
export const _internal = { interpreterArgv, shellQuote, parseListingOutput, buildTar };
|
|
1344
|
+
//# sourceMappingURL=sandbox-providers.js.map
|